diff --git a/examples/app_hub/hub.css b/examples/app_hub/hub.css
index a542d38..f03e938 100644
--- a/examples/app_hub/hub.css
+++ b/examples/app_hub/hub.css
@@ -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;
diff --git a/examples/crash_reporter/backend/README.md b/examples/crash_reporter/backend/README.md
index b89b193..4d21e3e 100644
--- a/examples/crash_reporter/backend/README.md
+++ b/examples/crash_reporter/backend/README.md
@@ -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
diff --git a/examples/crash_reporter/backend/nginx.apps-port80.fragment b/examples/crash_reporter/backend/nginx.apps-port80.fragment
index 1dbc581..0584f3e 100644
--- a/examples/crash_reporter/backend/nginx.apps-port80.fragment
+++ b/examples/crash_reporter/backend/nginx.apps-port80.fragment
@@ -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;
diff --git a/examples/crash_reporter/backend/public/api/heartbeat.php b/examples/crash_reporter/backend/public/api/heartbeat.php
index f88a5d7..8e7996e 100644
--- a/examples/crash_reporter/backend/public/api/heartbeat.php
+++ b/examples/crash_reporter/backend/public/api/heartbeat.php
@@ -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;
}
diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css
index 74f55ae..966191b 100644
--- a/examples/crash_reporter/backend/public/assets/css/app.css
+++ b/examples/crash_reporter/backend/public/assets/css/app.css
@@ -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; }
diff --git a/examples/crash_reporter/backend/public/assets/js/graphs.js b/examples/crash_reporter/backend/public/assets/js/graphs.js
index ad7e7e6..97471ae 100644
--- a/examples/crash_reporter/backend/public/assets/js/graphs.js
+++ b/examples/crash_reporter/backend/public/assets/js/graphs.js
@@ -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) => `
${String(r.label || '')}: ${Number(r.value || 0)}
`)
+ 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 (
+ '' +
+ escapeHtml(String(r.label || '')) +
+ '' +
+ Number(r.value || 0) +
+ ' (' +
+ pct +
+ '%)
'
+ );
+ })
.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 (
+ ''
+ );
+ }
+ return '' + label + '' + val + '
';
+ })
+ .join('');
+ }
+
+ function escapeHtml(s) {
+ return s
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ 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) =>
+ '' +
+ escapeHtml(pair[0]) +
+ ''
+ )
+ .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';
diff --git a/examples/crash_reporter/backend/scripts/generate_fake_graph_sessions.php b/examples/crash_reporter/backend/scripts/generate_fake_graph_sessions.php
index 9fd6fbc..4fad68d 100644
--- a/examples/crash_reporter/backend/scripts/generate_fake_graph_sessions.php
+++ b/examples/crash_reporter/backend/scripts/generate_fake_graph_sessions.php
@@ -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],
];
diff --git a/examples/crash_reporter/backend/scripts/test_graphs_api.sh b/examples/crash_reporter/backend/scripts/test_graphs_api.sh
new file mode 100755
index 0000000..c10ed34
--- /dev/null
+++ b/examples/crash_reporter/backend/scripts/test_graphs_api.sh
@@ -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 <&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"
diff --git a/examples/crash_reporter/backend/scripts/upload_fake_graph_sessions.php b/examples/crash_reporter/backend/scripts/upload_fake_graph_sessions.php
index 21a963d..101f8c8 100644
--- a/examples/crash_reporter/backend/scripts/upload_fake_graph_sessions.php
+++ b/examples/crash_reporter/backend/scripts/upload_fake_graph_sessions.php
@@ -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';
diff --git a/examples/crash_reporter/backend/sql/migrations/006_graph_sessions.sql b/examples/crash_reporter/backend/sql/migrations/006_graph_sessions.sql
new file mode 100644
index 0000000..820bb04
--- /dev/null
+++ b/examples/crash_reporter/backend/sql/migrations/006_graph_sessions.sql
@@ -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;
diff --git a/examples/crash_reporter/backend/src/GraphRepository.php b/examples/crash_reporter/backend/src/GraphRepository.php
index 105ed34..58586f4 100644
--- a/examples/crash_reporter/backend/src/GraphRepository.php
+++ b/examples/crash_reporter/backend/src/GraphRepository.php
@@ -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 */
+ 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 = ?';
diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php
index 2f44a41..02b1cf9 100644
--- a/examples/crash_reporter/backend/views/layout.php
+++ b/examples/crash_reporter/backend/views/layout.php
@@ -235,7 +235,7 @@
-
+
+
Loading…
-
-
- User scope
-
-
-
- Slug-admin scope
-
-
-
- Platform scope
-
-
-
- Bitrate (kbps)
-
-
-
- NTP source mix
- —
-
-
- Install source mix
- —
-
-
-
- APIs: = h(Auth::basePath()) ?>/api/graphs.php and
+
+
+ Your activity
+
+
Sessions / day
+
Unique devices / day
+
Avg duration (s) / day
+
Send sessions / day
+
Receive sessions / day
+
Success ratio
—
+
Outbound kbps / day
+
Inbound kbps / day
+
Crashes / day
+
+
+
+
+ Company (slug admin)
+
+
Sessions / day
+
Unique devices / day
+
Install sources
+
NTP sources
+
Transport mix
+
App versions
+
Crashes / day
+
Tickets / day
+
+
+
+
+ Platform reliability
+
+
Sessions / day (all slugs)
+
Crashes / day
+
Tickets / day
+
Install sources
+
NTP correction
—
+
Top crash fingerprints
+
Crashes by app version
+
+
+
+
diff --git a/orchestration/deploy.sh b/orchestration/deploy.sh
index 7394b38..df11f56 100755
--- a/orchestration/deploy.sh
+++ b/orchestration/deploy.sh
@@ -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
diff --git a/orchestration/nginx/crashes-fe.conf b/orchestration/nginx/crashes-fe.conf
index 36c2e94..cc52003 100644
--- a/orchestration/nginx/crashes-fe.conf
+++ b/orchestration/nginx/crashes-fe.conf
@@ -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;
diff --git a/orchestration/nginx/landing.conf b/orchestration/nginx/landing.conf
index a5a73ac..3a7e797 100644
--- a/orchestration/nginx/landing.conf
+++ b/orchestration/nginx/landing.conf
@@ -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;
diff --git a/orchestration/runtime/db-init/060_build_ecosystem.sql b/orchestration/runtime/db-init/060_build_ecosystem.sql
index 7091d13..95228cb 100644
--- a/orchestration/runtime/db-init/060_build_ecosystem.sql
+++ b/orchestration/runtime/db-init/060_build_ecosystem.sql
@@ -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,
diff --git a/orchestration/runtime/db-init/070_graph_ecosystem.sql b/orchestration/runtime/db-init/070_graph_ecosystem.sql
new file mode 100644
index 0000000..752a59e
--- /dev/null
+++ b/orchestration/runtime/db-init/070_graph_ecosystem.sql
@@ -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;