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 ( + '
' + + label + + '' + + val + + '
' + ); + } + 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 @@ -
+

AndroidCast graphs

@@ -249,6 +249,7 @@