1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 06:39:09 +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; 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 { .hub-page .bottom-bar {
position: relative; position: relative;
z-index: 1; z-index: 1;

View File

@@ -213,28 +213,45 @@ MariaDB (**required on existing servers**): `mysql -u root -p androidcast_crashe
## Graphs dashboard (`/graphs`) ## 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/` - `https://apps.f0xx.org/app/androidcast_project/graphs/`
- Uses the same auth/session, users, RBAC, and DB as crashes/tickets. - Same PHP session/RBAC as crashes, tickets, and builder (`session_cookie_path` `/app/androidcast_project`).
- Hub card: `AndroidCast graphs` (between crashes and builder). - 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: APIs:
- `GET /app/androidcast_project/graphs/api/graphs.php?days=14` (dashboard data in user/slug_admin/platform scopes) - `GET /graphs/api/graphs.php?days=7` — role-scoped metrics (`user`, `slug_admin`, `platform_admin`)
- `POST /app/androidcast_project/graphs/api/graph_upload.php` (ingest `graph_session` payloads) - `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 ```bash
cd examples/crash_reporter/backend
php scripts/generate_fake_graph_sessions.php --count=240 --days=30 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 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: Payload (`schema_version=1`): `session_id`, `company_id`, `user_id`, `device_id`, `app_version`,
`session_id`, `company_id`, `user_id`, `started_at_epoch_ms`, `ended_at_epoch_ms`, `duration_s`, `sdk_int`, timing, `completed`, `direction`, `transport`, `avg_kbps`, `recv_kbps`,
`direction`, `transport`, `avg_kbps`, `packet_loss_pct`, `ntp_source`, `install_source`, `meta`. `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 ## 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/; 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 { location = /app/androidcast_project/crashes/api/upload.php {
include fastcgi_params; include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket; fastcgi_pass unix:/run/php-fpm.socket;

View File

@@ -38,9 +38,16 @@ $resp = [
if ($type === 'ntp') { if ($type === 'ntp') {
$clientDate = (int)($heartbeat['date'] ?? 0); $clientDate = (int)($heartbeat['date'] ?? 0);
$clientTz = (string)($heartbeat['tz'] ?? ''); $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_date'] = $clientDate;
$resp['heartbeat']['client_tz'] = $clientTz; $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; $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-modal-close:hover { color: var(--text); background: var(--row-hover); }
.tag-editor--modal { margin: 0; border: none; border-radius: 0; } .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 () { (function () {
const COLORS = ['#3d8bfd', '#5eead4', '#f59e0b', '#34d399', '#a78bfa', '#f472b6', '#94a3b8'];
function basePath() { function basePath() {
return document.body.getAttribute('data-base-path') || ''; return document.body.getAttribute('data-base-path') || '';
} }
function el(id) {
return document.getElementById(id);
}
function drawLineChart(canvas, series, color) { function drawLineChart(canvas, series, color) {
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@@ -10,27 +16,35 @@
const w = canvas.width; const w = canvas.width;
const h = canvas.height; const h = canvas.height;
ctx.clearRect(0, 0, w, h); 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); ctx.fillRect(0, 0, w, h);
const points = Array.isArray(series) ? series : []; 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; let maxY = 0;
points.forEach((p) => { points.forEach((p) => {
const y = Number(p.y || 0); const y = Number(p.y || 0);
if (y > maxY) maxY = y; if (y > maxY) maxY = y;
}); });
if (maxY <= 0) maxY = 1; if (maxY <= 0) maxY = 1;
const pad = 20; const padL = 36;
const plotW = w - pad * 2; const padR = 12;
const plotH = h - pad * 2; 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; ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) { for (let i = 0; i <= 4; i++) {
const y = pad + (plotH * i) / 4; const y = padT + (plotH * i) / 4;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(pad, y); ctx.moveTo(padL, y);
ctx.lineTo(w - pad, y); ctx.lineTo(w - padR, y);
ctx.stroke(); ctx.stroke();
} }
@@ -38,34 +52,194 @@
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
points.forEach((p, idx) => { points.forEach((p, idx) => {
const x = pad + (plotW * idx) / Math.max(1, points.length - 1); const x = padL + (plotW * idx) / Math.max(1, points.length - 1);
const y = pad + plotH - (plotH * Number(p.y || 0)) / maxY; const y = padT + plotH - (plotH * Number(p.y || 0)) / maxY;
if (idx === 0) ctx.moveTo(x, y); if (idx === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y); else ctx.lineTo(x, y);
}); });
ctx.stroke(); 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) { function drawPie(canvas, rows) {
if (!el) return; if (!canvas) return;
if (!Array.isArray(rows) || !rows.length) { const ctx = canvas.getContext('2d');
el.textContent = 'No data'; 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; return;
} }
el.innerHTML = rows const total = data.reduce((s, r) => s + Number(r.value || 0), 0);
.map((r) => `<div><strong>${String(r.label || '')}</strong>: ${Number(r.value || 0)}</div>`) 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(''); .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() { function bootGraphs() {
const app = document.getElementById('graphs-app'); const app = el('graphs-app');
if (!app) return; if (!app) return;
const status = document.getElementById('graphs-status'); const status = el('graphs-status');
const daysSel = document.getElementById('graphs-days'); const daysSel = el('graphs-days');
function load() { function load() {
const days = Number(daysSel && daysSel.value ? daysSel.value : 14); const days = Number(daysSel && daysSel.value ? daysSel.value : 14);
status.textContent = 'Loading...'; status.textContent = 'Loading';
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/graphs.php?days=' + encodeURIComponent(String(days)), true); xhr.open('GET', basePath() + '/api/graphs.php?days=' + encodeURIComponent(String(days)), true);
xhr.onload = function () { xhr.onload = function () {
@@ -81,13 +255,13 @@
return; return;
} }
const data = payload.data || {}; const data = payload.data || {};
drawLineChart(document.getElementById('graph-user-sessions'), data.user && data.user.sessions_per_day, '#3d8bfd'); const viewer = data.viewer || 'user';
drawLineChart(document.getElementById('graph-slug-sessions'), data.slug_admin && data.slug_admin.sessions_per_day, '#5eead4'); renderLinks(data.links || {});
drawLineChart(document.getElementById('graph-platform-sessions'), data.platform_admin && data.platform_admin.sessions_per_day, '#f59e0b'); setScopeVisibility(viewer);
drawLineChart(document.getElementById('graph-bitrate'), data.platform_admin && data.platform_admin.avg_bitrate_kbps_per_day, '#34d399'); renderScope('user', data.user);
renderBreakdown(document.getElementById('graph-ntp-breakdown'), data.platform_admin && data.platform_admin.ntp_sources); if (data.slug_admin) renderScope('slug', data.slug_admin);
renderBreakdown(document.getElementById('graph-install-breakdown'), data.platform_admin && data.platform_admin.install_sources); if (data.platform_admin) renderScope('platform', data.platform_admin);
status.textContent = 'Loaded'; status.textContent = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd';
}; };
xhr.onerror = function () { xhr.onerror = function () {
status.textContent = 'Network error'; status.textContent = 'Network error';

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
$opts = getopt('', ['count::', 'days::', 'out::', 'company::', 'user::']); $opts = getopt('', ['count::', 'days::', 'out::', 'company::', 'user::']);
$count = max(10, (int) ($opts['count'] ?? 180)); $count = max(10, (int) ($opts['count'] ?? 180));
$days = max(1, (int) ($opts['days'] ?? 14)); $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); $companyId = (int) ($opts['company'] ?? 1);
$userId = (int) ($opts['user'] ?? 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), 'session_id' => 'fake-' . gmdate('Ymd', (int) ($start / 1000)) . '-' . substr(sha1((string) ($start . ':' . $i)), 0, 12),
'company_id' => $companyId, 'company_id' => $companyId,
'user_id' => $userId, '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, 'started_at_epoch_ms' => $start,
'ended_at_epoch_ms' => $end, 'ended_at_epoch_ms' => $end,
'duration_s' => $duration, 'duration_s' => $duration,
'completed' => ($i % 17 !== 0),
'direction' => ($i % 3 === 0) ? 'receiver' : 'sender', 'direction' => ($i % 3 === 0) ? 'receiver' : 'sender',
'transport' => $transports[array_rand($transports)], 'transport' => $transports[array_rand($transports)],
'avg_kbps' => random_int(640, 12000), 'avg_kbps' => random_int(640, 12000),
'recv_kbps' => random_int(200, 4000),
'packet_loss_pct' => round(mt_rand(0, 600) / 100, 2), 'packet_loss_pct' => round(mt_rand(0, 600) / 100, 2),
'ntp_source' => $ntp[array_rand($ntp)], 'ntp_source' => $ntp[array_rand($ntp)],
'ntp_correction_s' => random_int(0, 8),
'install_source' => $installs[array_rand($installs)], 'install_source' => $installs[array_rand($installs)],
'meta' => ['fake' => true, 'seed' => $i], '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); declare(strict_types=1);
$opts = getopt('', ['file::', 'url::', 'dry-run']); $opts = getopt('', ['file::', 'url::', 'dry-run']);
$file = $opts['file'] ?? (__DIR__ . '/../../../../tmp/fake_graph_sessions.ndjson'); $file = $opts['file'] ?? '/tmp/fake_graph_sessions.ndjson';
$base = rtrim($opts['url'] ?? 'https://apps.f0xx.org/app/androidcast_project/graphs', '/'); $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']); $dryRun = isset($opts['dry-run']);
$api = $base . '/api/graph_upload.php'; $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 { final class GraphRepository {
public static function ensureSchema(): void { public static function ensureSchema(): void {
$pdo = Database::pdo(); $pdo = Database::pdo();
if (Database::tableExists($pdo, 'graph_sessions')) { if (!Database::tableExists($pdo, 'graph_sessions')) {
self::createTable($pdo);
return; return;
} }
self::ensureColumns($pdo);
}
private static function createTable(PDO $pdo): void {
if (Database::isMysql()) { if (Database::isMysql()) {
$pdo->exec( $pdo->exec(
"CREATE TABLE IF NOT EXISTS graph_sessions ( "CREATE TABLE IF NOT EXISTS graph_sessions (
@@ -14,19 +19,27 @@ final class GraphRepository {
session_id VARCHAR(96) NOT NULL, session_id VARCHAR(96) NOT NULL,
company_id INT UNSIGNED NOT NULL DEFAULT 1, company_id INT UNSIGNED NOT NULL DEFAULT 1,
user_id INT UNSIGNED NULL, 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, started_at_ms BIGINT NOT NULL,
ended_at_ms BIGINT NOT NULL, ended_at_ms BIGINT NOT NULL,
duration_s INT NOT NULL, duration_s INT NOT NULL,
completed TINYINT(1) NOT NULL DEFAULT 1,
direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender', direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender',
transport VARCHAR(24) NOT NULL DEFAULT 'wifi', transport VARCHAR(24) NOT NULL DEFAULT 'wifi',
avg_kbps INT NOT NULL DEFAULT 0, 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, packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0,
ntp_source VARCHAR(32) NOT NULL DEFAULT 'device', ntp_source VARCHAR(32) NOT NULL DEFAULT 'device',
ntp_correction_s INT NULL,
install_source VARCHAR(32) NOT NULL DEFAULT 'direct', install_source VARCHAR(32) NOT NULL DEFAULT 'direct',
meta_json LONGTEXT NULL, meta_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_graph_session_id (session_id), 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" ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
); );
return; return;
@@ -37,14 +50,20 @@ final class GraphRepository {
session_id TEXT NOT NULL UNIQUE, session_id TEXT NOT NULL UNIQUE,
company_id INTEGER NOT NULL DEFAULT 1, company_id INTEGER NOT NULL DEFAULT 1,
user_id INTEGER NULL, user_id INTEGER NULL,
device_id TEXT NULL,
app_version TEXT NULL,
sdk_int INTEGER NULL,
started_at_ms INTEGER NOT NULL, started_at_ms INTEGER NOT NULL,
ended_at_ms INTEGER NOT NULL, ended_at_ms INTEGER NOT NULL,
duration_s INTEGER NOT NULL, duration_s INTEGER NOT NULL,
completed INTEGER NOT NULL DEFAULT 1,
direction TEXT NOT NULL DEFAULT 'sender', direction TEXT NOT NULL DEFAULT 'sender',
transport TEXT NOT NULL DEFAULT 'wifi', transport TEXT NOT NULL DEFAULT 'wifi',
avg_kbps INTEGER NOT NULL DEFAULT 0, avg_kbps INTEGER NOT NULL DEFAULT 0,
recv_kbps INTEGER NOT NULL DEFAULT 0,
packet_loss_pct REAL NOT NULL DEFAULT 0.0, packet_loss_pct REAL NOT NULL DEFAULT 0.0,
ntp_source TEXT NOT NULL DEFAULT 'device', ntp_source TEXT NOT NULL DEFAULT 'device',
ntp_correction_s INTEGER NULL,
install_source TEXT NOT NULL DEFAULT 'direct', install_source TEXT NOT NULL DEFAULT 'direct',
meta_json TEXT, meta_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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 { public static function insertSession(array $in): void {
self::ensureSchema(); self::ensureSchema();
$pdo = Database::pdo(); $pdo = Database::pdo();
@@ -72,7 +112,7 @@ final class GraphRepository {
if ($transport === '') { if ($transport === '') {
$transport = 'wifi'; $transport = 'wifi';
} }
$ntp = strtolower(trim((string) ($in['ntp_source'] ?? 'device'))); $ntp = strtolower(trim((string) ($in['ntp_source'] ?? ($in['time_source'] ?? 'device'))));
if ($ntp === '') { if ($ntp === '') {
$ntp = 'device'; $ntp = 'device';
} }
@@ -82,6 +122,7 @@ final class GraphRepository {
} }
$duration = max(0, (int) ($in['duration_s'] ?? (int) (($end - $start) / 1000))); $duration = max(0, (int) ($in['duration_s'] ?? (int) (($end - $start) / 1000)));
$avgKbps = max(0, (int) ($in['avg_kbps'] ?? 0)); $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)); $loss = max(0.0, (float) ($in['packet_loss_pct'] ?? 0.0));
$companyId = (int) ($in['company_id'] ?? Rbac::defaultCompanyId()); $companyId = (int) ($in['company_id'] ?? Rbac::defaultCompanyId());
if ($companyId <= 0) { if ($companyId <= 0) {
@@ -91,39 +132,75 @@ final class GraphRepository {
if ($userId !== null && $userId <= 0) { if ($userId !== null && $userId <= 0) {
$userId = null; $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; $meta = $in['meta'] ?? null;
$metaJson = is_array($meta) ? safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null; $metaJson = is_array($meta) ? safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null;
if (Database::isMysql()) { if (Database::isMysql()) {
$sql = 'INSERT INTO graph_sessions $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) (session_id, company_id, user_id, device_id, app_version, sdk_int,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 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 ON DUPLICATE KEY UPDATE
ended_at_ms = VALUES(ended_at_ms), ended_at_ms = VALUES(ended_at_ms),
duration_s = VALUES(duration_s), duration_s = VALUES(duration_s),
completed = VALUES(completed),
avg_kbps = VALUES(avg_kbps), avg_kbps = VALUES(avg_kbps),
recv_kbps = VALUES(recv_kbps),
packet_loss_pct = VALUES(packet_loss_pct), packet_loss_pct = VALUES(packet_loss_pct),
ntp_source = VALUES(ntp_source), ntp_source = VALUES(ntp_source),
ntp_correction_s = VALUES(ntp_correction_s),
install_source = VALUES(install_source), 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)'; meta_json = VALUES(meta_json)';
} else { } else {
$sql = 'INSERT OR REPLACE INTO graph_sessions $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) (session_id, company_id, user_id, device_id, app_version, sdk_int,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; 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 = $pdo->prepare($sql);
$stmt->execute([ $stmt->execute([
$sessionId, $sessionId,
$companyId, $companyId,
$userId, $userId,
$deviceId !== '' ? $deviceId : null,
$appVersion !== '' ? $appVersion : null,
$sdkInt,
$start, $start,
$end, $end,
$duration, $duration,
$completed,
$dir, $dir,
$transport, $transport,
$avgKbps, $avgKbps,
$recvKbps,
$loss, $loss,
$ntp, $ntp,
$ntpCorrection,
$install, $install,
$metaJson, $metaJson,
]); ]);
@@ -131,79 +208,139 @@ final class GraphRepository {
public static function dashboardData(int $days = 14): array { public static function dashboardData(int $days = 14): array {
self::ensureSchema(); self::ensureSchema();
$days = max(3, min(60, $days)); $days = max(1, min(60, $days));
$user = Auth::user(); $user = Auth::user();
$uid = (int) ($user['id'] ?? 0); $uid = (int) ($user['id'] ?? 0);
$scopeUserId = $uid > 0 ? $uid : null; $scopeUserId = $uid > 0 ? $uid : null;
$activeCompanyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId(); $activeCompanyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
$viewer = self::viewerRole($user);
return [ $payload = [
'window_days' => $days, 'window_days' => $days,
'user' => self::buildScope($days, $activeCompanyId, $scopeUserId), 'viewer' => $viewer,
'slug_admin' => self::buildScope($days, $activeCompanyId, null), 'links' => self::serviceLinks(),
'platform_admin' => self::buildScope($days, null, null), ];
$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); $startMs = (int) ((time() - ($days * 86400)) * 1000);
return [ $scope = [
'sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId), '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), '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), 'ntp_sources' => self::breakdown('ntp_source', $startMs, $companyId, $userId),
'install_sources' => self::breakdown('install_source', $startMs, $companyId, $userId), 'install_sources' => self::breakdown('install_source', $startMs, $companyId, $userId),
'transport_mix' => self::breakdown('transport', $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 >= ?']; $where = ['started_at_ms >= ?'];
$params = [$startMs]; $params = [$startMs];
self::appendScope($where, $params, $companyId, $userId); self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT started_at_ms 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);
$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';
$stmt = Database::pdo()->prepare($sql); $stmt = Database::pdo()->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
$sum = []; $sum = [];
$cnt = []; $cnt = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$day = gmdate('Y-m-d', ((int) ($row['started_at_ms'] ?? 0)) / 1000); $day = gmdate('Y-m-d', intdiv((int) ($row['started_at_ms'] ?? 0), 1000));
$sum[$day] = ($sum[$day] ?? 0.0) + (float) ($row['avg_kbps'] ?? 0); $sum[$day] = ($sum[$day] ?? 0) + (int) ($row['duration_s'] ?? 0);
$cnt[$day] = ($cnt[$day] ?? 0) + 1; $cnt[$day] = ($cnt[$day] ?? 0) + 1;
} }
$avg = []; $avg = [];
@@ -213,25 +350,169 @@ final class GraphRepository {
return self::assocToSeries($avg); return self::assocToSeries($avg);
} }
private static function breakdown(string $column, int $startMs, ?int $companyId, ?int $userId): array { private static function successRatio(int $startMs, ?int $companyId, ?int $userId): float {
$allowed = ['ntp_source', 'install_source', 'transport']; $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)) { if (!in_array($column, $allowed, true)) {
return []; return [];
} }
$where = ['started_at_ms >= ?']; $where = ['started_at_ms >= ?'];
$params = [$startMs]; $params = [$startMs];
self::appendScope($where, $params, $companyId, $userId); 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 ' $sql = 'SELECT ' . $column . ' AS k, COUNT(*) AS c FROM graph_sessions WHERE '
. implode(' AND ', $where) . ' GROUP BY ' . $column . ' ORDER BY c DESC'; . implode(' AND ', $where) . ' GROUP BY ' . $column . ' ORDER BY c DESC';
$stmt = Database::pdo()->prepare($sql); $stmt = Database::pdo()->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
$out = []; $out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { 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; 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 { private static function appendScope(array &$where, array &$params, ?int $companyId, ?int $userId): void {
if ($companyId !== null && $companyId > 0) { if ($companyId !== null && $companyId > 0) {
$where[] = 'company_id = ?'; $where[] = 'company_id = ?';

View File

@@ -235,7 +235,7 @@
<nav class="reports-pagination" id="reports-pagination" aria-label="Reports pages"></nav> <nav class="reports-pagination" id="reports-pagination" aria-label="Reports pages"></nav>
</div> </div>
<?php elseif (($view ?? '') === 'graphs'): ?> <?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"> <div class="toolbar reports-toolbar">
<h1>AndroidCast graphs</h1> <h1>AndroidCast graphs</h1>
<div class="toolbar-actions"> <div class="toolbar-actions">
@@ -249,6 +249,7 @@
<label class="toolbar-select"> <label class="toolbar-select">
<span>Window</span> <span>Window</span>
<select id="graphs-days" aria-label="Graphs window"> <select id="graphs-days" aria-label="Graphs window">
<option value="1">1d</option>
<option value="7">7d</option> <option value="7">7d</option>
<option value="14" selected>14d</option> <option value="14" selected>14d</option>
<option value="30">30d</option> <option value="30">30d</option>
@@ -256,36 +257,56 @@
</label> </label>
</div> </div>
</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> <p id="graphs-status" class="reports-status muted" aria-live="polite">Loading…</p>
<div class="cards cards--lift">
<article class="card card--lift"> <section class="graphs-scope" data-graph-scope="user">
<h3>User scope</h3> <h2 class="graphs-scope-title">Your activity</h2>
<canvas id="graph-user-sessions" width="560" height="180"></canvas> <div class="cards cards--lift graphs-grid">
</article> <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"> <article class="card card--lift"><h3>Unique devices / day</h3><canvas id="graph-user-devices" width="520" height="160"></canvas></article>
<h3>Slug-admin scope</h3> <article class="card card--lift"><h3>Avg duration (s) / day</h3><canvas id="graph-user-duration" width="520" height="160"></canvas></article>
<canvas id="graph-slug-sessions" width="560" height="180"></canvas> <article class="card card--lift"><h3>Send sessions / day</h3><canvas id="graph-user-send" width="520" height="160"></canvas></article>
</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"> <article class="card card--lift"><h3>Success ratio</h3><p id="graph-user-success" class="graph-kpi">—</p></article>
<h3>Platform scope</h3> <article class="card card--lift"><h3>Outbound kbps / day</h3><canvas id="graph-user-bitrate" width="520" height="160"></canvas></article>
<canvas id="graph-platform-sessions" width="560" height="180"></canvas> <article class="card card--lift"><h3>Inbound kbps / day</h3><canvas id="graph-user-recv-kbps" width="520" height="160"></canvas></article>
</article> <article class="card card--lift"><h3>Crashes / day</h3><canvas id="graph-user-crashes" width="520" height="160"></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> </div>
<p class="muted"> </section>
APIs: <code><?= h(Auth::basePath()) ?>/api/graphs.php</code> and
<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> <code><?= h(Auth::basePath()) ?>/api/graph_upload.php</code>
· Read:
<code><?= h(Auth::basePath()) ?>/api/graphs.php</code>
</p> </p>
</div> </div>
<?php endif; ?> <?php endif; ?>

View File

@@ -110,19 +110,25 @@ return [
]; ];
EOF 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" cp "$SRC_SCHEMA" "$DB_INIT_DIR/010_schema.sql"
{ write_migration_with_db "$SRC_MIG_004" "$DB_INIT_DIR/040_ticket_workflow.sql"
echo "USE \`${MARIADB_DATABASE}\`;" write_migration_with_db "$SRC_MIG_005" "$DB_INIT_DIR/050_ticket_attachments.sql"
cat "$SRC_MIG_004" write_migration_with_db "$SRC_MIG_060" "$DB_INIT_DIR/060_build_ecosystem.sql"
} >"$DB_INIT_DIR/040_ticket_workflow.sql" write_migration_with_db "$SRC_MIG_070" "$DB_INIT_DIR/070_graph_ecosystem.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"
echo "Pulling images..." echo "Pulling images..."
docker compose -f "$ROOT_DIR/docker-compose.yml" pull landing-fe gitea-fe crashes-fe build-fe mariadb gitea || true 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 "Landing: ${PUBLIC_BASE_URL}/app/androidcast_project/"
echo "Gitea (direct FE): http://localhost:${GITEA_FE_PORT}/app/androidcast_project/git/" 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 "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 "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 "Shared login: same user/password as Crashes (session cookie path /app/androidcast_project)"
echo echo

View File

@@ -9,6 +9,43 @@ server {
return 302 /app/androidcast_project/crashes/; 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/ { location /app/androidcast_project/crashes/ {
alias /workspace/examples/crash_reporter/backend/public/; alias /workspace/examples/crash_reporter/backend/public/;
index index.php; index index.php;

View File

@@ -21,6 +21,15 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; 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/ { location /app/androidcast_project/build/ {
proxy_pass http://build-fe:8080; proxy_pass http://build-fe:8080;
proxy_http_version 1.1; proxy_http_version 1.1;

View File

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