1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:57:40 +03:00

Graphs dashboard: role-scoped metrics, orchestration, and smoke tests.

Extend graph_sessions schema and GraphRepository dashboards (user/slug/platform),
wire nginx/orchestration routes, fix gmdate timestamps, and add API smoke script.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-06-02 18:51:37 +02:00
parent 5e940690e9
commit f885d194dd
17 changed files with 913 additions and 140 deletions

View File

@@ -322,6 +322,25 @@
box-shadow: 0 -4px 0 currentColor, 0 4px 0 currentColor;
}
.nav-icon--graphs {
width: 16px;
height: 16px;
border-left: 2px solid currentColor;
border-bottom: 2px solid currentColor;
position: relative;
}
.nav-icon--graphs::before,
.nav-icon--graphs::after {
content: "";
position: absolute;
bottom: 0;
width: 3px;
background: currentColor;
border-radius: 1px 1px 0 0;
}
.nav-icon--graphs::before { left: 4px; height: 8px; }
.nav-icon--graphs::after { left: 10px; height: 12px; }
.hub-page .bottom-bar {
position: relative;
z-index: 1;

View File

@@ -213,28 +213,45 @@ MariaDB (**required on existing servers**): `mysql -u root -p androidcast_crashe
## Graphs dashboard (`/graphs`)
Stage-1 custom BE graphs are available at:
Custom Grafana-style BE dashboards (direct MariaDB reads, no ETL):
- `https://apps.f0xx.org/app/androidcast_project/graphs/`
- Uses the same auth/session, users, RBAC, and DB as crashes/tickets.
- Hub card: `AndroidCast graphs` (between crashes and builder).
- Same PHP session/RBAC as crashes, tickets, and builder (`session_cookie_path` `/app/androidcast_project`).
- Hub card: **AndroidCast graphs** (quick links to crashes, tickets, builder, git).
**MariaDB (existing server):** run once as root:
```sh
mysql -u root -p androidcast_crashes < sql/migrations/006_graph_sessions.sql
```
Table auto-creates on first ingest if missing (SQLite dev); production MariaDB should use migration **006**.
APIs:
- `GET /app/androidcast_project/graphs/api/graphs.php?days=14` (dashboard data in user/slug_admin/platform scopes)
- `POST /app/androidcast_project/graphs/api/graph_upload.php` (ingest `graph_session` payloads)
- `GET /graphs/api/graphs.php?days=7` — role-scoped metrics (`user`, `slug_admin`, `platform_admin`)
- `POST /graphs/api/graph_upload.php` — device/session ingest (`ingest_kind=graph_session`, no session cookie)
Fake data helper scripts:
Smoke test:
```bash
./scripts/test_graphs_api.sh
BASE=http://localhost:8082/app/androidcast_project/crashes ./scripts/test_graphs_api.sh
```
Fake data:
```bash
cd examples/crash_reporter/backend
php scripts/generate_fake_graph_sessions.php --count=240 --days=30
php scripts/upload_fake_graph_sessions.php --url=https://apps.f0xx.org/app/androidcast_project/graphs
```
Payload shape (`schema_version=1`, `ingest_kind=graph_session`) includes:
`session_id`, `company_id`, `user_id`, `started_at_epoch_ms`, `ended_at_epoch_ms`, `duration_s`,
`direction`, `transport`, `avg_kbps`, `packet_loss_pct`, `ntp_source`, `install_source`, `meta`.
Payload (`schema_version=1`): `session_id`, `company_id`, `user_id`, `device_id`, `app_version`,
`sdk_int`, timing, `completed`, `direction`, `transport`, `avg_kbps`, `recv_kbps`,
`packet_loss_pct`, `ntp_source`, `ntp_correction_s`, `install_source`, optional `meta`.
NTP heartbeat (`POST …/api/heartbeat.php`, `type=ntp`) returns `time_source` and `ntp_correction_s`
for clients that sync clocks before upload.
## Default accounts

View File

@@ -26,6 +26,10 @@ server {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
}
location ^~ /app/androidcast_project/graphs/assets/ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
}
location = /app/androidcast_project/crashes/api/upload.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;

View File

@@ -38,9 +38,16 @@ $resp = [
if ($type === 'ntp') {
$clientDate = (int)($heartbeat['date'] ?? 0);
$clientTz = (string)($heartbeat['tz'] ?? '');
$timeSource = trim((string)($heartbeat['time_source'] ?? 'device'));
if ($timeSource === '') {
$timeSource = 'device';
}
$correction = $clientDate > 0 ? ($srvEpoch - $clientDate) : null;
$resp['heartbeat']['client_date'] = $clientDate;
$resp['heartbeat']['client_tz'] = $clientTz;
$resp['heartbeat']['correction_seconds'] = $clientDate > 0 ? ($srvEpoch - $clientDate) : null;
$resp['heartbeat']['time_source'] = $timeSource;
$resp['heartbeat']['correction_seconds'] = $correction;
$resp['heartbeat']['ntp_correction_s'] = $correction;
$resp['heartbeat']['enabled'] = true;
}

View File

@@ -1223,3 +1223,41 @@ a:hover { text-decoration: underline; }
}
.tag-modal-close:hover { color: var(--text); background: var(--row-hover); }
.tag-editor--modal { margin: 0; border: none; border-radius: 0; }
.graphs-app .graphs-quick-links {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 12px;
}
.graph-quick-link { font-size: 0.9rem; }
.graphs-scope { margin-top: 20px; }
.graphs-scope-title {
margin: 0 0 12px;
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.graphs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.graphs-grid canvas { width: 100%; height: auto; max-width: 100%; }
.graph-kpi {
font-size: 2rem;
font-weight: 700;
margin: 12px 0 0;
color: var(--accent, #3d8bfd);
}
.graph-breakdown { font-size: 0.92rem; }
.graph-breakdown-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 0;
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
}
.graph-breakdown-row a { color: var(--accent, #3d8bfd); text-decoration: none; }
.graph-breakdown-row a:hover { text-decoration: underline; }
.graphs-footnote { margin-top: 20px; font-size: 0.88rem; }

View File

@@ -1,8 +1,14 @@
(function () {
const COLORS = ['#3d8bfd', '#5eead4', '#f59e0b', '#34d399', '#a78bfa', '#f472b6', '#94a3b8'];
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function el(id) {
return document.getElementById(id);
}
function drawLineChart(canvas, series, color) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
@@ -10,27 +16,35 @@
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = 'rgba(90,110,140,0.15)';
ctx.fillStyle = 'rgba(90,110,140,0.12)';
ctx.fillRect(0, 0, w, h);
const points = Array.isArray(series) ? series : [];
if (!points.length) return;
if (!points.length) {
ctx.fillStyle = '#94a3b8';
ctx.font = '12px system-ui, sans-serif';
ctx.fillText('No data', 24, h / 2);
return;
}
let maxY = 0;
points.forEach((p) => {
const y = Number(p.y || 0);
if (y > maxY) maxY = y;
});
if (maxY <= 0) maxY = 1;
const pad = 20;
const plotW = w - pad * 2;
const plotH = h - pad * 2;
const padL = 36;
const padR = 12;
const padT = 12;
const padB = 28;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
ctx.strokeStyle = 'rgba(120,140,170,0.5)';
ctx.strokeStyle = 'rgba(120,140,170,0.45)';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = pad + (plotH * i) / 4;
const y = padT + (plotH * i) / 4;
ctx.beginPath();
ctx.moveTo(pad, y);
ctx.lineTo(w - pad, y);
ctx.moveTo(padL, y);
ctx.lineTo(w - padR, y);
ctx.stroke();
}
@@ -38,34 +52,194 @@
ctx.lineWidth = 2;
ctx.beginPath();
points.forEach((p, idx) => {
const x = pad + (plotW * idx) / Math.max(1, points.length - 1);
const y = pad + plotH - (plotH * Number(p.y || 0)) / maxY;
const x = padL + (plotW * idx) / Math.max(1, points.length - 1);
const y = padT + plotH - (plotH * Number(p.y || 0)) / maxY;
if (idx === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.fillStyle = '#94a3b8';
ctx.font = '10px system-ui, sans-serif';
if (points.length) {
ctx.fillText(String(points[0].x || ''), padL, h - 8);
const last = points[points.length - 1];
const lx = padL + plotW - 48;
ctx.fillText(String(last.x || ''), lx, h - 8);
}
}
function renderBreakdown(el, rows) {
if (!el) return;
if (!Array.isArray(rows) || !rows.length) {
el.textContent = 'No data';
function drawPie(canvas, rows) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
const data = Array.isArray(rows) ? rows.filter((r) => Number(r.value) > 0) : [];
if (!data.length) {
ctx.fillStyle = '#94a3b8';
ctx.font = '12px system-ui, sans-serif';
ctx.fillText('No data', 24, h / 2);
return;
}
el.innerHTML = rows
.map((r) => `<div><strong>${String(r.label || '')}</strong>: ${Number(r.value || 0)}</div>`)
const total = data.reduce((s, r) => s + Number(r.value || 0), 0);
const cx = w * 0.35;
const cy = h * 0.5;
const r = Math.min(w, h) * 0.32;
let angle = -Math.PI / 2;
data.forEach((row, i) => {
const slice = (Number(row.value) / total) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.fillStyle = COLORS[i % COLORS.length];
ctx.arc(cx, cy, r, angle, angle + slice);
ctx.closePath();
ctx.fill();
angle += slice;
});
let ly = 16;
data.forEach((row, i) => {
ctx.fillStyle = COLORS[i % COLORS.length];
ctx.fillRect(w * 0.62, ly, 10, 10);
ctx.fillStyle = '#cbd5e1';
ctx.font = '11px system-ui, sans-serif';
const pct = total > 0 ? Math.round((100 * Number(row.value)) / total) : 0;
ctx.fillText(String(row.label || '') + ' (' + pct + '%)', w * 0.62 + 16, ly + 9);
ly += 18;
});
}
function renderBreakdownList(target, rows) {
if (!target) return;
if (!Array.isArray(rows) || !rows.length) {
target.textContent = 'No data';
return;
}
const total = rows.reduce((s, r) => s + Number(r.value || 0), 0) || 1;
target.innerHTML = rows
.map((r) => {
const pct = Math.round((100 * Number(r.value || 0)) / total);
return (
'<div class="graph-breakdown-row"><span>' +
escapeHtml(String(r.label || '')) +
'</span><strong>' +
Number(r.value || 0) +
' <span class="muted">(' +
pct +
'%)</span></strong></div>'
);
})
.join('');
}
function renderTopList(target, rows) {
if (!target) return;
if (!Array.isArray(rows) || !rows.length) {
target.textContent = 'No data';
return;
}
target.innerHTML = rows
.map((r) => {
const label = escapeHtml(String(r.label || ''));
const val = Number(r.value || 0);
if (r.href) {
return (
'<div class="graph-breakdown-row"><a href="' +
escapeHtml(r.href) +
'">' +
label +
'</a><strong>' +
val +
'</strong></div>'
);
}
return '<div class="graph-breakdown-row"><span>' + label + '</span><strong>' + val + '</strong></div>';
})
.join('');
}
function escapeHtml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderScope(prefix, scope) {
if (!scope) return;
drawLineChart(el('graph-' + prefix + '-sessions'), scope.sessions_per_day, COLORS[0]);
drawLineChart(el('graph-' + prefix + '-devices'), scope.unique_devices_per_day, COLORS[1]);
drawLineChart(el('graph-' + prefix + '-duration'), scope.avg_duration_s_per_day, COLORS[2]);
drawLineChart(el('graph-' + prefix + '-send'), scope.send_sessions_per_day, COLORS[3]);
drawLineChart(el('graph-' + prefix + '-recv'), scope.recv_sessions_per_day, COLORS[4]);
drawLineChart(el('graph-' + prefix + '-bitrate'), scope.avg_bitrate_kbps_per_day, COLORS[0]);
drawLineChart(el('graph-' + prefix + '-recv-kbps'), scope.avg_recv_kbps_per_day, COLORS[1]);
drawLineChart(el('graph-' + prefix + '-crashes'), scope.crashes_per_day, '#f87171');
if (scope.tickets_per_day) {
drawLineChart(el('graph-' + prefix + '-tickets'), scope.tickets_per_day, '#c084fc');
}
drawPie(el('graph-' + prefix + '-install-pie'), scope.install_sources);
drawPie(el('graph-' + prefix + '-ntp-pie'), scope.ntp_sources);
renderBreakdownList(el('graph-' + prefix + '-transport'), scope.transport_mix);
renderBreakdownList(el('graph-' + prefix + '-versions'), scope.app_versions);
const ratioEl = el('graph-' + prefix + '-success');
if (ratioEl) {
ratioEl.textContent =
typeof scope.success_ratio_pct === 'number' ? scope.success_ratio_pct + '%' : '—';
}
const ntpAvg = el('graph-' + prefix + '-ntp-avg');
if (ntpAvg && scope.ntp_correction_avg_s != null) {
ntpAvg.textContent = scope.ntp_correction_avg_s + ' s avg correction';
}
renderTopList(el('graph-' + prefix + '-fingerprints'), scope.top_crash_fingerprints);
drawPie(el('graph-' + prefix + '-crash-versions'), scope.crashes_by_app_version);
}
function renderLinks(links) {
const nav = el('graphs-quick-links');
if (!nav || !links) return;
const items = [
['Hub', links.hub],
['Crashes', links.crashes],
['Tickets', links.tickets],
['Builder', links.build],
['Git', links.git],
];
nav.innerHTML = items
.filter((pair) => pair[1])
.map(
(pair) =>
'<a class="btn btn-secondary graph-quick-link" href="' +
escapeHtml(pair[1]) +
'">' +
escapeHtml(pair[0]) +
'</a>'
)
.join('');
}
function setScopeVisibility(viewer) {
document.querySelectorAll('[data-graph-scope]').forEach((section) => {
const need = section.getAttribute('data-graph-scope');
let show = false;
if (need === 'user') show = true;
if (need === 'slug_admin' && (viewer === 'slug_admin' || viewer === 'platform_admin')) show = true;
if (need === 'platform_admin' && viewer === 'platform_admin') show = true;
section.hidden = !show;
});
}
function bootGraphs() {
const app = document.getElementById('graphs-app');
const app = el('graphs-app');
if (!app) return;
const status = document.getElementById('graphs-status');
const daysSel = document.getElementById('graphs-days');
const status = el('graphs-status');
const daysSel = el('graphs-days');
function load() {
const days = Number(daysSel && daysSel.value ? daysSel.value : 14);
status.textContent = 'Loading...';
status.textContent = 'Loading';
const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/graphs.php?days=' + encodeURIComponent(String(days)), true);
xhr.onload = function () {
@@ -81,13 +255,13 @@
return;
}
const data = payload.data || {};
drawLineChart(document.getElementById('graph-user-sessions'), data.user && data.user.sessions_per_day, '#3d8bfd');
drawLineChart(document.getElementById('graph-slug-sessions'), data.slug_admin && data.slug_admin.sessions_per_day, '#5eead4');
drawLineChart(document.getElementById('graph-platform-sessions'), data.platform_admin && data.platform_admin.sessions_per_day, '#f59e0b');
drawLineChart(document.getElementById('graph-bitrate'), data.platform_admin && data.platform_admin.avg_bitrate_kbps_per_day, '#34d399');
renderBreakdown(document.getElementById('graph-ntp-breakdown'), data.platform_admin && data.platform_admin.ntp_sources);
renderBreakdown(document.getElementById('graph-install-breakdown'), data.platform_admin && data.platform_admin.install_sources);
status.textContent = 'Loaded';
const viewer = data.viewer || 'user';
renderLinks(data.links || {});
setScopeVisibility(viewer);
renderScope('user', data.user);
if (data.slug_admin) renderScope('slug', data.slug_admin);
if (data.platform_admin) renderScope('platform', data.platform_admin);
status.textContent = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd';
};
xhr.onerror = function () {
status.textContent = 'Network error';

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
$opts = getopt('', ['count::', 'days::', 'out::', 'company::', 'user::']);
$count = max(10, (int) ($opts['count'] ?? 180));
$days = max(1, (int) ($opts['days'] ?? 14));
$out = $opts['out'] ?? (__DIR__ . '/../../../../tmp/fake_graph_sessions.ndjson');
$out = $opts['out'] ?? '/tmp/fake_graph_sessions.ndjson';
$companyId = (int) ($opts['company'] ?? 1);
$userId = (int) ($opts['user'] ?? 1);
@@ -31,14 +31,20 @@ for ($i = 0; $i < $count; $i++) {
'session_id' => 'fake-' . gmdate('Ymd', (int) ($start / 1000)) . '-' . substr(sha1((string) ($start . ':' . $i)), 0, 12),
'company_id' => $companyId,
'user_id' => $userId,
'device_id' => 'fake-dev-' . ($i % 40),
'app_version' => '0.1.' . ($i % 5),
'sdk_int' => 29 + ($i % 6),
'started_at_epoch_ms' => $start,
'ended_at_epoch_ms' => $end,
'duration_s' => $duration,
'completed' => ($i % 17 !== 0),
'direction' => ($i % 3 === 0) ? 'receiver' : 'sender',
'transport' => $transports[array_rand($transports)],
'avg_kbps' => random_int(640, 12000),
'recv_kbps' => random_int(200, 4000),
'packet_loss_pct' => round(mt_rand(0, 600) / 100, 2),
'ntp_source' => $ntp[array_rand($ntp)],
'ntp_correction_s' => random_int(0, 8),
'install_source' => $installs[array_rand($installs)],
'meta' => ['fake' => true, 'seed' => $i],
];

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Smoke-test graph session ingest + dashboard API.
#
# ./scripts/test_graphs_api.sh
# GRAPHS_BASE=http://localhost:8080/app/androidcast_project/graphs CRASH_USER=admin CRASH_PASS=admin ./scripts/test_graphs_api.sh
# COOKIE='ac_crash_sess=…' GRAPHS_BASE=https://apps.f0xx.org/app/androidcast_project/graphs ./scripts/test_graphs_api.sh
set -euo pipefail
CRASH_BASE="${CRASH_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
GRAPHS_BASE="${GRAPHS_BASE:-${CRASH_BASE/\/crashes\//\/graphs\/}}"
UPLOAD_URL="${GRAPHS_BASE}/api/graph_upload.php"
API_URL="${GRAPHS_BASE}/api/graphs.php"
COOKIE_JAR="${TMPDIR:-/tmp}/graph_api_cookies_$$.txt"
trap 'rm -f "$COOKIE_JAR"' EXIT
CURL_AUTH=()
if [[ -n "${COOKIE:-}" ]]; then
CURL_AUTH=(-H "Cookie: ${COOKIE}")
else
user="${CRASH_USER:-admin}"
pass="${CRASH_PASS:-admin}"
curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \
-d "username=${user}&password=${pass}" \
"${CRASH_BASE}/login" -o /dev/null
CURL_AUTH=(-b "$COOKIE_JAR")
fi
now_ms="$(date +%s)000"
start_ms=$((now_ms - 600000))
sid="smoke-graph-$(date +%s)"
payload="$(cat <<EOF
{
"schema_version": 1,
"ingest_kind": "graph_session",
"session_id": "${sid}",
"company_id": 1,
"user_id": 1,
"device_id": "smoke-device-01",
"app_version": "0.1.0-smoke",
"sdk_int": 34,
"started_at_epoch_ms": ${start_ms},
"ended_at_epoch_ms": ${now_ms},
"duration_s": 600,
"completed": true,
"direction": "sender",
"transport": "wifi",
"avg_kbps": 4200,
"recv_kbps": 800,
"packet_loss_pct": 0.4,
"ntp_source": "backend",
"ntp_correction_s": 2,
"install_source": "ota",
"meta": {"smoke": true}
}
EOF
)"
echo "POST ${UPLOAD_URL}"
upload_code="$(curl -sS -o /tmp/graph_upload_resp.json -w '%{http_code}' \
"${CURL_AUTH[@]}" \
-H 'Content-Type: application/json' \
-d "${payload}" \
"${UPLOAD_URL}")"
echo "upload HTTP ${upload_code}"
cat /tmp/graph_upload_resp.json
echo
echo "GET ${API_URL}?days=7"
graphs_code="$(curl -sS -o /tmp/graphs_resp.json -w '%{http_code}' \
"${CURL_AUTH[@]}" \
"${API_URL}?days=7")"
echo "graphs HTTP ${graphs_code}"
head -c 400 /tmp/graphs_resp.json
echo
echo "…"
if [[ "${upload_code}" != "200" ]]; then
echo "FAIL: upload expected 200" >&2
exit 1
fi
if [[ "${graphs_code}" != "200" ]]; then
echo "FAIL: graphs API expected 200 (got ${graphs_code})" >&2
exit 1
fi
if ! grep -q '"ok":true' /tmp/graphs_resp.json; then
echo "FAIL: graphs API body missing ok:true" >&2
exit 1
fi
echo "OK"

View File

@@ -3,8 +3,12 @@
declare(strict_types=1);
$opts = getopt('', ['file::', 'url::', 'dry-run']);
$file = $opts['file'] ?? (__DIR__ . '/../../../../tmp/fake_graph_sessions.ndjson');
$base = rtrim($opts['url'] ?? 'https://apps.f0xx.org/app/androidcast_project/graphs', '/');
$file = $opts['file'] ?? '/tmp/fake_graph_sessions.ndjson';
$base = (string) ($opts['url'] ?? 'https://apps.f0xx.org/app/androidcast_project/graphs');
if ($base === '' || $base === '1') {
$base = 'https://apps.f0xx.org/app/androidcast_project/graphs';
}
$base = rtrim($base, '/');
$dryRun = isset($opts['dry-run']);
$api = $base . '/api/graph_upload.php';

View File

@@ -0,0 +1,30 @@
-- Graph session metrics (direct MariaDB ingest; no ETL).
-- Run as root on existing servers: mysql -u root -p androidcast_crashes < sql/migrations/006_graph_sessions.sql
CREATE TABLE IF NOT EXISTS graph_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(96) NOT NULL,
company_id INT UNSIGNED NOT NULL DEFAULT 1,
user_id INT UNSIGNED NULL,
device_id VARCHAR(128) NULL,
app_version VARCHAR(64) NULL,
sdk_int INT NULL,
started_at_ms BIGINT NOT NULL,
ended_at_ms BIGINT NOT NULL,
duration_s INT NOT NULL,
completed TINYINT(1) NOT NULL DEFAULT 1,
direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender',
transport VARCHAR(24) NOT NULL DEFAULT 'wifi',
avg_kbps INT NOT NULL DEFAULT 0,
recv_kbps INT NOT NULL DEFAULT 0,
packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0,
ntp_source VARCHAR(32) NOT NULL DEFAULT 'device',
ntp_correction_s INT NULL,
install_source VARCHAR(32) NOT NULL DEFAULT 'direct',
meta_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_graph_session_id (session_id),
KEY idx_graph_company_time (company_id, started_at_ms),
KEY idx_graph_device (device_id),
KEY idx_graph_app_version (app_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -4,9 +4,14 @@ declare(strict_types=1);
final class GraphRepository {
public static function ensureSchema(): void {
$pdo = Database::pdo();
if (Database::tableExists($pdo, 'graph_sessions')) {
if (!Database::tableExists($pdo, 'graph_sessions')) {
self::createTable($pdo);
return;
}
self::ensureColumns($pdo);
}
private static function createTable(PDO $pdo): void {
if (Database::isMysql()) {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS graph_sessions (
@@ -14,19 +19,27 @@ final class GraphRepository {
session_id VARCHAR(96) NOT NULL,
company_id INT UNSIGNED NOT NULL DEFAULT 1,
user_id INT UNSIGNED NULL,
device_id VARCHAR(128) NULL,
app_version VARCHAR(64) NULL,
sdk_int INT NULL,
started_at_ms BIGINT NOT NULL,
ended_at_ms BIGINT NOT NULL,
duration_s INT NOT NULL,
completed TINYINT(1) NOT NULL DEFAULT 1,
direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender',
transport VARCHAR(24) NOT NULL DEFAULT 'wifi',
avg_kbps INT NOT NULL DEFAULT 0,
recv_kbps INT NOT NULL DEFAULT 0,
packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0,
ntp_source VARCHAR(32) NOT NULL DEFAULT 'device',
ntp_correction_s INT NULL,
install_source VARCHAR(32) NOT NULL DEFAULT 'direct',
meta_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_graph_session_id (session_id),
KEY idx_graph_company_time (company_id, started_at_ms)
KEY idx_graph_company_time (company_id, started_at_ms),
KEY idx_graph_device (device_id),
KEY idx_graph_app_version (app_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
return;
@@ -37,14 +50,20 @@ final class GraphRepository {
session_id TEXT NOT NULL UNIQUE,
company_id INTEGER NOT NULL DEFAULT 1,
user_id INTEGER NULL,
device_id TEXT NULL,
app_version TEXT NULL,
sdk_int INTEGER NULL,
started_at_ms INTEGER NOT NULL,
ended_at_ms INTEGER NOT NULL,
duration_s INTEGER NOT NULL,
completed INTEGER NOT NULL DEFAULT 1,
direction TEXT NOT NULL DEFAULT 'sender',
transport TEXT NOT NULL DEFAULT 'wifi',
avg_kbps INTEGER NOT NULL DEFAULT 0,
recv_kbps INTEGER NOT NULL DEFAULT 0,
packet_loss_pct REAL NOT NULL DEFAULT 0.0,
ntp_source TEXT NOT NULL DEFAULT 'device',
ntp_correction_s INTEGER NULL,
install_source TEXT NOT NULL DEFAULT 'direct',
meta_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -52,6 +71,27 @@ final class GraphRepository {
);
}
private static function ensureColumns(PDO $pdo): void {
$cols = [
'device_id' => Database::isMysql() ? 'VARCHAR(128) NULL' : 'TEXT NULL',
'app_version' => Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL',
'sdk_int' => Database::isMysql() ? 'INT NULL' : 'INTEGER NULL',
'completed' => Database::isMysql() ? 'TINYINT(1) NOT NULL DEFAULT 1' : 'INTEGER NOT NULL DEFAULT 1',
'recv_kbps' => Database::isMysql() ? 'INT NOT NULL DEFAULT 0' : 'INTEGER NOT NULL DEFAULT 0',
'ntp_correction_s' => Database::isMysql() ? 'INT NULL' : 'INTEGER NULL',
];
foreach ($cols as $name => $ddl) {
try {
$pdo->exec('ALTER TABLE graph_sessions ADD COLUMN ' . $name . ' ' . $ddl);
} catch (PDOException $e) {
$msg = strtolower($e->getMessage());
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
throw $e;
}
}
}
}
public static function insertSession(array $in): void {
self::ensureSchema();
$pdo = Database::pdo();
@@ -72,7 +112,7 @@ final class GraphRepository {
if ($transport === '') {
$transport = 'wifi';
}
$ntp = strtolower(trim((string) ($in['ntp_source'] ?? 'device')));
$ntp = strtolower(trim((string) ($in['ntp_source'] ?? ($in['time_source'] ?? 'device'))));
if ($ntp === '') {
$ntp = 'device';
}
@@ -82,6 +122,7 @@ final class GraphRepository {
}
$duration = max(0, (int) ($in['duration_s'] ?? (int) (($end - $start) / 1000)));
$avgKbps = max(0, (int) ($in['avg_kbps'] ?? 0));
$recvKbps = max(0, (int) ($in['recv_kbps'] ?? 0));
$loss = max(0.0, (float) ($in['packet_loss_pct'] ?? 0.0));
$companyId = (int) ($in['company_id'] ?? Rbac::defaultCompanyId());
if ($companyId <= 0) {
@@ -91,39 +132,75 @@ final class GraphRepository {
if ($userId !== null && $userId <= 0) {
$userId = null;
}
$deviceId = trim((string) ($in['device_id'] ?? ''));
if ($deviceId === '' && is_array($in['device'] ?? null)) {
$deviceId = trim((string) (($in['device']['id'] ?? $in['device']['device_id'] ?? '')));
}
$appVersion = trim((string) ($in['app_version'] ?? ''));
if ($appVersion === '' && is_array($in['app'] ?? null)) {
$appVersion = trim((string) ($in['app']['version_name'] ?? $in['app']['version'] ?? ''));
}
$sdkInt = isset($in['sdk_int']) ? (int) $in['sdk_int'] : null;
if ($sdkInt === null && is_array($in['device'] ?? null)) {
$sdkInt = isset($in['device']['sdk_int']) ? (int) $in['device']['sdk_int'] : null;
}
$completed = 1;
if (array_key_exists('completed', $in)) {
$completed = !empty($in['completed']) ? 1 : 0;
}
$ntpCorrection = isset($in['ntp_correction_s']) ? (int) $in['ntp_correction_s'] : null;
if ($ntpCorrection === null && isset($in['ntp_correction_seconds'])) {
$ntpCorrection = (int) $in['ntp_correction_seconds'];
}
$meta = $in['meta'] ?? null;
$metaJson = is_array($meta) ? safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null;
if (Database::isMysql()) {
$sql = 'INSERT INTO graph_sessions
(session_id, company_id, user_id, started_at_ms, ended_at_ms, duration_s, direction, transport, avg_kbps, packet_loss_pct, ntp_source, install_source, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(session_id, company_id, user_id, device_id, app_version, sdk_int,
started_at_ms, ended_at_ms, duration_s, completed, direction, transport,
avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
ended_at_ms = VALUES(ended_at_ms),
duration_s = VALUES(duration_s),
completed = VALUES(completed),
avg_kbps = VALUES(avg_kbps),
recv_kbps = VALUES(recv_kbps),
packet_loss_pct = VALUES(packet_loss_pct),
ntp_source = VALUES(ntp_source),
ntp_correction_s = VALUES(ntp_correction_s),
install_source = VALUES(install_source),
device_id = COALESCE(VALUES(device_id), device_id),
app_version = COALESCE(VALUES(app_version), app_version),
sdk_int = COALESCE(VALUES(sdk_int), sdk_int),
meta_json = VALUES(meta_json)';
} else {
$sql = 'INSERT OR REPLACE INTO graph_sessions
(session_id, company_id, user_id, started_at_ms, ended_at_ms, duration_s, direction, transport, avg_kbps, packet_loss_pct, ntp_source, install_source, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
(session_id, company_id, user_id, device_id, app_version, sdk_int,
started_at_ms, ended_at_ms, duration_s, completed, direction, transport,
avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
}
$stmt = $pdo->prepare($sql);
$stmt->execute([
$sessionId,
$companyId,
$userId,
$deviceId !== '' ? $deviceId : null,
$appVersion !== '' ? $appVersion : null,
$sdkInt,
$start,
$end,
$duration,
$completed,
$dir,
$transport,
$avgKbps,
$recvKbps,
$loss,
$ntp,
$ntpCorrection,
$install,
$metaJson,
]);
@@ -131,79 +208,139 @@ final class GraphRepository {
public static function dashboardData(int $days = 14): array {
self::ensureSchema();
$days = max(3, min(60, $days));
$days = max(1, min(60, $days));
$user = Auth::user();
$uid = (int) ($user['id'] ?? 0);
$scopeUserId = $uid > 0 ? $uid : null;
$activeCompanyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
$viewer = self::viewerRole($user);
return [
$payload = [
'window_days' => $days,
'user' => self::buildScope($days, $activeCompanyId, $scopeUserId),
'slug_admin' => self::buildScope($days, $activeCompanyId, null),
'platform_admin' => self::buildScope($days, null, null),
'viewer' => $viewer,
'links' => self::serviceLinks(),
];
$payload['user'] = self::buildScope($days, $activeCompanyId, $scopeUserId);
if ($viewer === 'user') {
return $payload;
}
$payload['slug_admin'] = self::buildScope($days, $activeCompanyId, null);
if ($viewer !== 'platform_admin') {
return $payload;
}
$payload['platform_admin'] = self::buildScope($days, null, null, true);
return $payload;
}
private static function viewerRole(?array $user): string {
if (Rbac::isGlobalAdmin($user)) {
return 'platform_admin';
}
$companyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
$uid = (int) ($user['id'] ?? 0);
$role = ($companyId > 0 && $uid > 0) ? Rbac::companyRole($uid, $companyId) : null;
if ($role === 'owner' || $role === 'admin') {
return 'slug_admin';
}
return 'user';
}
/** @return array<string, string> */
private static function serviceLinks(): array {
$prefix = '/app/androidcast_project';
return [
'hub' => $prefix . '/',
'graphs' => $prefix . '/graphs/',
'crashes' => $prefix . '/crashes/?view=reports',
'tickets' => $prefix . '/crashes/?view=tickets',
'build' => $prefix . '/build/',
'git' => $prefix . '/git/',
];
}
private static function buildScope(int $days, ?int $companyId, ?int $userId): array {
private static function buildScope(int $days, ?int $companyId, ?int $userId, bool $platform = false): array {
$startMs = (int) ((time() - ($days * 86400)) * 1000);
return [
$scope = [
'sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId),
'unique_devices_per_day' => self::uniqueDevicesPerDay($startMs, $companyId, $userId),
'avg_duration_s_per_day' => self::avgDurationPerDay($startMs, $companyId, $userId),
'send_sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId, 'sender'),
'recv_sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId, 'receiver'),
'success_ratio_pct' => self::successRatio($startMs, $companyId, $userId),
'avg_bitrate_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId, 'avg_kbps'),
'avg_recv_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId, 'recv_kbps'),
'crashes_per_day' => self::crashesPerDay($startMs, $companyId),
'avg_bitrate_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId),
'ntp_sources' => self::breakdown('ntp_source', $startMs, $companyId, $userId),
'install_sources' => self::breakdown('install_source', $startMs, $companyId, $userId),
'transport_mix' => self::breakdown('transport', $startMs, $companyId, $userId),
'app_versions' => self::breakdown('app_version', $startMs, $companyId, $userId),
];
if (Database::tableExists(Database::pdo(), 'tickets')) {
$scope['tickets_per_day'] = self::ticketsPerDay($startMs, $companyId);
}
if ($platform) {
$scope['top_crash_fingerprints'] = self::topCrashFingerprints($startMs, 8);
$scope['crashes_by_app_version'] = self::crashesByAppVersion($startMs);
$scope['ntp_correction_avg_s'] = self::avgNtpCorrection($startMs, $companyId, $userId);
}
return $scope;
}
private static function sessionsPerDay(int $startMs, ?int $companyId, ?int $userId): array {
private static function sessionsPerDay(
int $startMs,
?int $companyId,
?int $userId,
?string $direction = null
): array {
$where = ['started_at_ms >= ?'];
$params = [$startMs];
if ($direction !== null) {
$where[] = 'direction = ?';
$params[] = $direction;
}
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT started_at_ms FROM graph_sessions WHERE ' . implode(' AND ', $where);
return self::countByDay($sql, $params, 'started_at_ms');
}
private static function uniqueDevicesPerDay(int $startMs, ?int $companyId, ?int $userId): array {
$where = ['started_at_ms >= ?', "device_id IS NOT NULL", "device_id <> ''"];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT started_at_ms, device_id FROM graph_sessions WHERE ' . implode(' AND ', $where);
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$bucket = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$day = gmdate('Y-m-d', intdiv((int) ($row['started_at_ms'] ?? 0), 1000));
$dev = (string) ($row['device_id'] ?? '');
if ($dev === '') {
continue;
}
if (!isset($bucket[$day])) {
$bucket[$day] = [];
}
$bucket[$day][$dev] = true;
}
$out = [];
foreach ($bucket as $day => $set) {
$out[$day] = count($set);
}
return self::assocToSeries($out);
}
private static function avgDurationPerDay(int $startMs, ?int $companyId, ?int $userId): array {
$where = ['started_at_ms >= ?'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT started_at_ms FROM graph_sessions WHERE ' . implode(' AND ', $where) . ' ORDER BY started_at_ms ASC';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$bucket = [];
foreach ($rows as $row) {
$day = gmdate('Y-m-d', ((int) ($row['started_at_ms'] ?? 0)) / 1000);
$bucket[$day] = ($bucket[$day] ?? 0) + 1;
}
return self::assocToSeries($bucket);
}
private static function crashesPerDay(int $startMs, ?int $companyId): array {
$where = ['received_at_ms >= ?'];
$params = [$startMs];
if ($companyId !== null && $companyId > 0) {
$where[] = 'company_id = ?';
$params[] = $companyId;
}
$sql = 'SELECT received_at_ms FROM reports WHERE ' . implode(' AND ', $where) . ' ORDER BY received_at_ms ASC';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$bucket = [];
foreach ($rows as $row) {
$day = gmdate('Y-m-d', ((int) ($row['received_at_ms'] ?? 0)) / 1000);
$bucket[$day] = ($bucket[$day] ?? 0) + 1;
}
return self::assocToSeries($bucket);
}
private static function avgBitratePerDay(int $startMs, ?int $companyId, ?int $userId): array {
$where = ['started_at_ms >= ?'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT started_at_ms, avg_kbps FROM graph_sessions WHERE ' . implode(' AND ', $where) . ' ORDER BY started_at_ms ASC';
$sql = 'SELECT started_at_ms, duration_s FROM graph_sessions WHERE ' . implode(' AND ', $where);
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$sum = [];
$cnt = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$day = gmdate('Y-m-d', ((int) ($row['started_at_ms'] ?? 0)) / 1000);
$sum[$day] = ($sum[$day] ?? 0.0) + (float) ($row['avg_kbps'] ?? 0);
$day = gmdate('Y-m-d', intdiv((int) ($row['started_at_ms'] ?? 0), 1000));
$sum[$day] = ($sum[$day] ?? 0) + (int) ($row['duration_s'] ?? 0);
$cnt[$day] = ($cnt[$day] ?? 0) + 1;
}
$avg = [];
@@ -213,25 +350,169 @@ final class GraphRepository {
return self::assocToSeries($avg);
}
private static function breakdown(string $column, int $startMs, ?int $companyId, ?int $userId): array {
$allowed = ['ntp_source', 'install_source', 'transport'];
private static function successRatio(int $startMs, ?int $companyId, ?int $userId): float {
$where = ['started_at_ms >= ?'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT COUNT(*) AS total,
SUM(CASE WHEN completed = 1 THEN 1 ELSE 0 END) AS ok
FROM graph_sessions WHERE ' . implode(' AND ', $where);
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
$total = (int) ($row['total'] ?? 0);
$ok = (int) ($row['ok'] ?? 0);
if ($total <= 0) {
return 0.0;
}
return round(100.0 * $ok / $total, 1);
}
private static function avgBitratePerDay(
int $startMs,
?int $companyId,
?int $userId,
string $column
): array {
$allowed = ['avg_kbps', 'recv_kbps'];
if (!in_array($column, $allowed, true)) {
return [];
}
$where = ['started_at_ms >= ?'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT started_at_ms, ' . $column . ' AS v FROM graph_sessions WHERE '
. implode(' AND ', $where);
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$sum = [];
$cnt = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$day = gmdate('Y-m-d', intdiv((int) ($row['started_at_ms'] ?? 0), 1000));
$sum[$day] = ($sum[$day] ?? 0.0) + (float) ($row['v'] ?? 0);
$cnt[$day] = ($cnt[$day] ?? 0) + 1;
}
$avg = [];
foreach ($sum as $day => $total) {
$avg[$day] = $cnt[$day] > 0 ? round($total / $cnt[$day], 1) : 0.0;
}
return self::assocToSeries($avg);
}
private static function crashesPerDay(int $startMs, ?int $companyId): array {
if (!Database::tableExists(Database::pdo(), 'reports')) {
return [];
}
$where = ['received_at_ms >= ?'];
$params = [$startMs];
if ($companyId !== null && $companyId > 0) {
$where[] = 'company_id = ?';
$params[] = $companyId;
}
$sql = 'SELECT received_at_ms FROM reports WHERE ' . implode(' AND ', $where);
return self::countByDay($sql, $params, 'received_at_ms');
}
private static function ticketsPerDay(int $startMs, ?int $companyId): array {
$where = ['opened_at_ms >= ?'];
$params = [$startMs];
if ($companyId !== null && $companyId > 0) {
$where[] = 'company_id = ?';
$params[] = $companyId;
}
$sql = 'SELECT opened_at_ms FROM tickets WHERE ' . implode(' AND ', $where);
return self::countByDay($sql, $params, 'opened_at_ms');
}
private static function topCrashFingerprints(int $startMs, int $limit): array {
if (!Database::tableExists(Database::pdo(), 'reports')) {
return [];
}
$sql = 'SELECT fingerprint, COUNT(*) AS c, MAX(id) AS last_id
FROM reports WHERE received_at_ms >= ?
GROUP BY fingerprint ORDER BY c DESC LIMIT ' . (int) $limit;
$stmt = Database::pdo()->prepare($sql);
$stmt->execute([$startMs]);
$base = Auth::basePath();
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$fp = (string) ($row['fingerprint'] ?? '');
$id = (int) ($row['last_id'] ?? 0);
$out[] = [
'label' => $fp,
'value' => (int) ($row['c'] ?? 0),
'href' => $id > 0 ? $base . '/?view=report&id=' . $id : null,
];
}
return $out;
}
private static function crashesByAppVersion(int $startMs): array {
if (!Database::tableExists(Database::pdo(), 'reports')) {
return [];
}
$sql = "SELECT COALESCE(NULLIF(app_version, ''), 'unknown') AS k, COUNT(*) AS c
FROM reports WHERE received_at_ms >= ?
GROUP BY k ORDER BY c DESC LIMIT 12";
$stmt = Database::pdo()->prepare($sql);
$stmt->execute([$startMs]);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
}
return $out;
}
private static function avgNtpCorrection(int $startMs, ?int $companyId, ?int $userId): ?float {
$where = ['started_at_ms >= ?', 'ntp_correction_s IS NOT NULL'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT AVG(ABS(ntp_correction_s)) AS avg_s FROM graph_sessions WHERE '
. implode(' AND ', $where);
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$v = $stmt->fetchColumn();
return $v === false || $v === null ? null : round((float) $v, 2);
}
private static function breakdown(string $column, int $startMs, ?int $companyId, ?int $userId): array {
$allowed = ['ntp_source', 'install_source', 'transport', 'app_version'];
if (!in_array($column, $allowed, true)) {
return [];
}
$where = ['started_at_ms >= ?'];
$params = [$startMs];
if ($column === 'app_version') {
$where[] = "app_version IS NOT NULL";
$where[] = "app_version <> ''";
}
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT ' . $column . ' AS k, COUNT(*) AS c FROM graph_sessions WHERE '
. implode(' AND ', $where) . ' GROUP BY ' . $column . ' ORDER BY c DESC';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
$label = (string) ($row['k'] ?? 'unknown');
if ($label === '') {
$label = 'unknown';
}
$out[] = ['label' => $label, 'value' => (int) ($row['c'] ?? 0)];
}
return $out;
}
private static function countByDay(string $sql, array $params, string $msColumn): array {
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$bucket = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$day = gmdate('Y-m-d', intdiv((int) ($row[$msColumn] ?? 0), 1000));
$bucket[$day] = ($bucket[$day] ?? 0) + 1;
}
return self::assocToSeries($bucket);
}
private static function appendScope(array &$where, array &$params, ?int $companyId, ?int $userId): void {
if ($companyId !== null && $companyId > 0) {
$where[] = 'company_id = ?';

View File

@@ -235,7 +235,7 @@
<nav class="reports-pagination" id="reports-pagination" aria-label="Reports pages"></nav>
</div>
<?php elseif (($view ?? '') === 'graphs'): ?>
<div id="graphs-app" class="reports-app">
<div id="graphs-app" class="reports-app graphs-app">
<div class="toolbar reports-toolbar">
<h1>AndroidCast graphs</h1>
<div class="toolbar-actions">
@@ -249,6 +249,7 @@
<label class="toolbar-select">
<span>Window</span>
<select id="graphs-days" aria-label="Graphs window">
<option value="1">1d</option>
<option value="7">7d</option>
<option value="14" selected>14d</option>
<option value="30">30d</option>
@@ -256,36 +257,56 @@
</label>
</div>
</div>
<nav id="graphs-quick-links" class="graphs-quick-links" aria-label="Related services"></nav>
<p id="graphs-status" class="reports-status muted" aria-live="polite">Loading…</p>
<div class="cards cards--lift">
<article class="card card--lift">
<h3>User scope</h3>
<canvas id="graph-user-sessions" width="560" height="180"></canvas>
</article>
<article class="card card--lift">
<h3>Slug-admin scope</h3>
<canvas id="graph-slug-sessions" width="560" height="180"></canvas>
</article>
<article class="card card--lift">
<h3>Platform scope</h3>
<canvas id="graph-platform-sessions" width="560" height="180"></canvas>
</article>
<article class="card card--lift">
<h3>Bitrate (kbps)</h3>
<canvas id="graph-bitrate" width="560" height="180"></canvas>
</article>
<article class="card card--lift">
<h3>NTP source mix</h3>
<div id="graph-ntp-breakdown" class="muted">—</div>
</article>
<article class="card card--lift">
<h3>Install source mix</h3>
<div id="graph-install-breakdown" class="muted"></div>
</article>
</div>
<p class="muted">
APIs: <code><?= h(Auth::basePath()) ?>/api/graphs.php</code> and
<section class="graphs-scope" data-graph-scope="user">
<h2 class="graphs-scope-title">Your activity</h2>
<div class="cards cards--lift graphs-grid">
<article class="card card--lift"><h3>Sessions / day</h3><canvas id="graph-user-sessions" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Unique devices / day</h3><canvas id="graph-user-devices" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Avg duration (s) / day</h3><canvas id="graph-user-duration" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Send sessions / day</h3><canvas id="graph-user-send" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Receive sessions / day</h3><canvas id="graph-user-recv" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Success ratio</h3><p id="graph-user-success" class="graph-kpi">—</p></article>
<article class="card card--lift"><h3>Outbound kbps / day</h3><canvas id="graph-user-bitrate" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Inbound kbps / day</h3><canvas id="graph-user-recv-kbps" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Crashes / day</h3><canvas id="graph-user-crashes" width="520" height="160"></canvas></article>
</div>
</section>
<section class="graphs-scope" data-graph-scope="slug_admin" hidden>
<h2 class="graphs-scope-title">Company (slug admin)</h2>
<div class="cards cards--lift graphs-grid">
<article class="card card--lift"><h3>Sessions / day</h3><canvas id="graph-slug-sessions" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Unique devices / day</h3><canvas id="graph-slug-devices" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Install sources</h3><canvas id="graph-slug-install-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>NTP sources</h3><canvas id="graph-slug-ntp-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>Transport mix</h3><div id="graph-slug-transport" class="graph-breakdown"></div></article>
<article class="card card--lift"><h3>App versions</h3><div id="graph-slug-versions" class="graph-breakdown"></div></article>
<article class="card card--lift"><h3>Crashes / day</h3><canvas id="graph-slug-crashes" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Tickets / day</h3><canvas id="graph-slug-tickets" width="520" height="160"></canvas></article>
</div>
</section>
<section class="graphs-scope" data-graph-scope="platform_admin" hidden>
<h2 class="graphs-scope-title">Platform reliability</h2>
<div class="cards cards--lift graphs-grid">
<article class="card card--lift"><h3>Sessions / day (all slugs)</h3><canvas id="graph-platform-sessions" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Crashes / day</h3><canvas id="graph-platform-crashes" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Tickets / day</h3><canvas id="graph-platform-tickets" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Install sources</h3><canvas id="graph-platform-install-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>NTP correction</h3><p id="graph-platform-ntp-avg" class="graph-kpi muted">—</p></article>
<article class="card card--lift"><h3>Top crash fingerprints</h3><div id="graph-platform-fingerprints" class="graph-breakdown"></div></article>
<article class="card card--lift"><h3>Crashes by app version</h3><canvas id="graph-platform-crash-versions" width="520" height="180"></canvas></article>
</div>
</section>
<p class="muted graphs-footnote">
Direct DB metrics (no ETL). Ingest:
<code><?= h(Auth::basePath()) ?>/api/graph_upload.php</code>
· Read:
<code><?= h(Auth::basePath()) ?>/api/graphs.php</code>
</p>
</div>
<?php endif; ?>

View File

@@ -110,19 +110,25 @@ return [
];
EOF
SRC_MIG_070="$ROOT_DIR/runtime/db-init/070_graph_ecosystem.sql"
write_migration_with_db() {
local src="$1"
local dest="$2"
local tmp
tmp="$(mktemp)"
{
echo "USE \`${MARIADB_DATABASE}\`;"
cat "$src"
} >"$tmp"
mv "$tmp" "$dest"
}
cp "$SRC_SCHEMA" "$DB_INIT_DIR/010_schema.sql"
{
echo "USE \`${MARIADB_DATABASE}\`;"
cat "$SRC_MIG_004"
} >"$DB_INIT_DIR/040_ticket_workflow.sql"
{
echo "USE \`${MARIADB_DATABASE}\`;"
cat "$SRC_MIG_005"
} >"$DB_INIT_DIR/050_ticket_attachments.sql"
{
echo "USE \`${MARIADB_DATABASE}\`;"
cat "$SRC_MIG_060"
} >"$DB_INIT_DIR/060_build_ecosystem.sql"
write_migration_with_db "$SRC_MIG_004" "$DB_INIT_DIR/040_ticket_workflow.sql"
write_migration_with_db "$SRC_MIG_005" "$DB_INIT_DIR/050_ticket_attachments.sql"
write_migration_with_db "$SRC_MIG_060" "$DB_INIT_DIR/060_build_ecosystem.sql"
write_migration_with_db "$SRC_MIG_070" "$DB_INIT_DIR/070_graph_ecosystem.sql"
echo "Pulling images..."
docker compose -f "$ROOT_DIR/docker-compose.yml" pull landing-fe gitea-fe crashes-fe build-fe mariadb gitea || true
@@ -135,6 +141,7 @@ echo "Stack is up."
echo "Landing: ${PUBLIC_BASE_URL}/app/androidcast_project/"
echo "Gitea (direct FE): http://localhost:${GITEA_FE_PORT}/app/androidcast_project/git/"
echo "Crashes (direct FE): http://localhost:${CRASHES_FE_PORT}/app/androidcast_project/crashes/"
echo "Graphs (via landing): ${PUBLIC_BASE_URL}/app/androidcast_project/graphs/"
echo "Builder (direct FE): http://localhost:${BUILD_FE_PORT:-8083}/app/androidcast_project/build/"
echo "Shared login: same user/password as Crashes (session cookie path /app/androidcast_project)"
echo

View File

@@ -9,6 +9,43 @@ server {
return 302 /app/androidcast_project/crashes/;
}
location = /app/androidcast_project/graphs {
return 302 /app/androidcast_project/graphs/;
}
location ^~ /app/androidcast_project/graphs/assets/ {
alias /workspace/examples/crash_reporter/backend/public/assets/;
}
location = /app/androidcast_project/graphs/api/graphs.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /workspace/examples/crash_reporter/backend/public/api/graphs.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php;
fastcgi_param DOCUMENT_ROOT /workspace/examples/crash_reporter/backend/public;
fastcgi_pass crash-php:9000;
fastcgi_read_timeout 120s;
}
location = /app/androidcast_project/graphs/api/graph_upload.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /workspace/examples/crash_reporter/backend/public/api/graph_upload.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php;
fastcgi_param DOCUMENT_ROOT /workspace/examples/crash_reporter/backend/public;
fastcgi_pass crash-php:9000;
client_max_body_size 4m;
fastcgi_read_timeout 120s;
}
location ^~ /app/androidcast_project/graphs/ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /workspace/examples/crash_reporter/backend/public/index.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_ROOT /workspace/examples/crash_reporter/backend/public;
fastcgi_pass crash-php:9000;
fastcgi_read_timeout 120s;
}
location /app/androidcast_project/crashes/ {
alias /workspace/examples/crash_reporter/backend/public/;
index index.php;

View File

@@ -21,6 +21,15 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /app/androidcast_project/graphs/ {
proxy_pass http://crashes-fe:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /app/androidcast_project/build/ {
proxy_pass http://build-fe:8080;
proxy_http_version 1.1;

View File

@@ -1,3 +1,4 @@
USE `androidcast_crashes`;
CREATE TABLE IF NOT EXISTS builds (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
build_code VARCHAR(64) NOT NULL,

View File

@@ -0,0 +1,28 @@
USE `androidcast_crashes`;
CREATE TABLE IF NOT EXISTS graph_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(96) NOT NULL,
company_id INT UNSIGNED NOT NULL DEFAULT 1,
user_id INT UNSIGNED NULL,
device_id VARCHAR(128) NULL,
app_version VARCHAR(64) NULL,
sdk_int INT NULL,
started_at_ms BIGINT NOT NULL,
ended_at_ms BIGINT NOT NULL,
duration_s INT NOT NULL,
completed TINYINT(1) NOT NULL DEFAULT 1,
direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender',
transport VARCHAR(24) NOT NULL DEFAULT 'wifi',
avg_kbps INT NOT NULL DEFAULT 0,
recv_kbps INT NOT NULL DEFAULT 0,
packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0,
ntp_source VARCHAR(32) NOT NULL DEFAULT 'device',
ntp_correction_s INT NULL,
install_source VARCHAR(32) NOT NULL DEFAULT 'direct',
meta_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_graph_session_id (session_id),
KEY idx_graph_company_time (company_id, started_at_ms),
KEY idx_graph_device (device_id),
KEY idx_graph_app_version (app_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;