mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:58:14 +03:00
Graphs dashboard: role-scoped metrics, orchestration, and smoke tests.
Extend graph_sessions schema and GraphRepository dashboards (user/slug/platform), wire nginx/orchestration routes, fix gmdate timestamps, and add API smoke script. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
(function () {
|
||||
const COLORS = ['#3d8bfd', '#5eead4', '#f59e0b', '#34d399', '#a78bfa', '#f472b6', '#94a3b8'];
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function drawLineChart(canvas, series, color) {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -10,27 +16,35 @@
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = 'rgba(90,110,140,0.15)';
|
||||
ctx.fillStyle = 'rgba(90,110,140,0.12)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
const points = Array.isArray(series) ? series : [];
|
||||
if (!points.length) return;
|
||||
if (!points.length) {
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.fillText('No data', 24, h / 2);
|
||||
return;
|
||||
}
|
||||
let maxY = 0;
|
||||
points.forEach((p) => {
|
||||
const y = Number(p.y || 0);
|
||||
if (y > maxY) maxY = y;
|
||||
});
|
||||
if (maxY <= 0) maxY = 1;
|
||||
const pad = 20;
|
||||
const plotW = w - pad * 2;
|
||||
const plotH = h - pad * 2;
|
||||
const padL = 36;
|
||||
const padR = 12;
|
||||
const padT = 12;
|
||||
const padB = 28;
|
||||
const plotW = w - padL - padR;
|
||||
const plotH = h - padT - padB;
|
||||
|
||||
ctx.strokeStyle = 'rgba(120,140,170,0.5)';
|
||||
ctx.strokeStyle = 'rgba(120,140,170,0.45)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = pad + (plotH * i) / 4;
|
||||
const y = padT + (plotH * i) / 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad, y);
|
||||
ctx.lineTo(w - pad, y);
|
||||
ctx.moveTo(padL, y);
|
||||
ctx.lineTo(w - padR, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
@@ -38,34 +52,194 @@
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
points.forEach((p, idx) => {
|
||||
const x = pad + (plotW * idx) / Math.max(1, points.length - 1);
|
||||
const y = pad + plotH - (plotH * Number(p.y || 0)) / maxY;
|
||||
const x = padL + (plotW * idx) / Math.max(1, points.length - 1);
|
||||
const y = padT + plotH - (plotH * Number(p.y || 0)) / maxY;
|
||||
if (idx === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '10px system-ui, sans-serif';
|
||||
if (points.length) {
|
||||
ctx.fillText(String(points[0].x || ''), padL, h - 8);
|
||||
const last = points[points.length - 1];
|
||||
const lx = padL + plotW - 48;
|
||||
ctx.fillText(String(last.x || ''), lx, h - 8);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBreakdown(el, rows) {
|
||||
if (!el) return;
|
||||
if (!Array.isArray(rows) || !rows.length) {
|
||||
el.textContent = 'No data';
|
||||
function drawPie(canvas, rows) {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const data = Array.isArray(rows) ? rows.filter((r) => Number(r.value) > 0) : [];
|
||||
if (!data.length) {
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.fillText('No data', 24, h / 2);
|
||||
return;
|
||||
}
|
||||
el.innerHTML = rows
|
||||
.map((r) => `<div><strong>${String(r.label || '')}</strong>: ${Number(r.value || 0)}</div>`)
|
||||
const total = data.reduce((s, r) => s + Number(r.value || 0), 0);
|
||||
const cx = w * 0.35;
|
||||
const cy = h * 0.5;
|
||||
const r = Math.min(w, h) * 0.32;
|
||||
let angle = -Math.PI / 2;
|
||||
data.forEach((row, i) => {
|
||||
const slice = (Number(row.value) / total) * Math.PI * 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.fillStyle = COLORS[i % COLORS.length];
|
||||
ctx.arc(cx, cy, r, angle, angle + slice);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
angle += slice;
|
||||
});
|
||||
let ly = 16;
|
||||
data.forEach((row, i) => {
|
||||
ctx.fillStyle = COLORS[i % COLORS.length];
|
||||
ctx.fillRect(w * 0.62, ly, 10, 10);
|
||||
ctx.fillStyle = '#cbd5e1';
|
||||
ctx.font = '11px system-ui, sans-serif';
|
||||
const pct = total > 0 ? Math.round((100 * Number(row.value)) / total) : 0;
|
||||
ctx.fillText(String(row.label || '') + ' (' + pct + '%)', w * 0.62 + 16, ly + 9);
|
||||
ly += 18;
|
||||
});
|
||||
}
|
||||
|
||||
function renderBreakdownList(target, rows) {
|
||||
if (!target) return;
|
||||
if (!Array.isArray(rows) || !rows.length) {
|
||||
target.textContent = 'No data';
|
||||
return;
|
||||
}
|
||||
const total = rows.reduce((s, r) => s + Number(r.value || 0), 0) || 1;
|
||||
target.innerHTML = rows
|
||||
.map((r) => {
|
||||
const pct = Math.round((100 * Number(r.value || 0)) / total);
|
||||
return (
|
||||
'<div class="graph-breakdown-row"><span>' +
|
||||
escapeHtml(String(r.label || '')) +
|
||||
'</span><strong>' +
|
||||
Number(r.value || 0) +
|
||||
' <span class="muted">(' +
|
||||
pct +
|
||||
'%)</span></strong></div>'
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderTopList(target, rows) {
|
||||
if (!target) return;
|
||||
if (!Array.isArray(rows) || !rows.length) {
|
||||
target.textContent = 'No data';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = rows
|
||||
.map((r) => {
|
||||
const label = escapeHtml(String(r.label || ''));
|
||||
const val = Number(r.value || 0);
|
||||
if (r.href) {
|
||||
return (
|
||||
'<div class="graph-breakdown-row"><a href="' +
|
||||
escapeHtml(r.href) +
|
||||
'">' +
|
||||
label +
|
||||
'</a><strong>' +
|
||||
val +
|
||||
'</strong></div>'
|
||||
);
|
||||
}
|
||||
return '<div class="graph-breakdown-row"><span>' + label + '</span><strong>' + val + '</strong></div>';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.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) =>
|
||||
'<a class="btn btn-secondary graph-quick-link" href="' +
|
||||
escapeHtml(pair[1]) +
|
||||
'">' +
|
||||
escapeHtml(pair[0]) +
|
||||
'</a>'
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function setScopeVisibility(viewer) {
|
||||
document.querySelectorAll('[data-graph-scope]').forEach((section) => {
|
||||
const need = section.getAttribute('data-graph-scope');
|
||||
let show = false;
|
||||
if (need === 'user') show = true;
|
||||
if (need === 'slug_admin' && (viewer === 'slug_admin' || viewer === 'platform_admin')) show = true;
|
||||
if (need === 'platform_admin' && viewer === 'platform_admin') show = true;
|
||||
section.hidden = !show;
|
||||
});
|
||||
}
|
||||
|
||||
function bootGraphs() {
|
||||
const app = document.getElementById('graphs-app');
|
||||
const app = el('graphs-app');
|
||||
if (!app) return;
|
||||
const status = document.getElementById('graphs-status');
|
||||
const daysSel = document.getElementById('graphs-days');
|
||||
const status = el('graphs-status');
|
||||
const daysSel = el('graphs-days');
|
||||
|
||||
function load() {
|
||||
const days = Number(daysSel && daysSel.value ? daysSel.value : 14);
|
||||
status.textContent = 'Loading...';
|
||||
status.textContent = 'Loading…';
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', basePath() + '/api/graphs.php?days=' + encodeURIComponent(String(days)), true);
|
||||
xhr.onload = function () {
|
||||
@@ -81,13 +255,13 @@
|
||||
return;
|
||||
}
|
||||
const data = payload.data || {};
|
||||
drawLineChart(document.getElementById('graph-user-sessions'), data.user && data.user.sessions_per_day, '#3d8bfd');
|
||||
drawLineChart(document.getElementById('graph-slug-sessions'), data.slug_admin && data.slug_admin.sessions_per_day, '#5eead4');
|
||||
drawLineChart(document.getElementById('graph-platform-sessions'), data.platform_admin && data.platform_admin.sessions_per_day, '#f59e0b');
|
||||
drawLineChart(document.getElementById('graph-bitrate'), data.platform_admin && data.platform_admin.avg_bitrate_kbps_per_day, '#34d399');
|
||||
renderBreakdown(document.getElementById('graph-ntp-breakdown'), data.platform_admin && data.platform_admin.ntp_sources);
|
||||
renderBreakdown(document.getElementById('graph-install-breakdown'), data.platform_admin && data.platform_admin.install_sources);
|
||||
status.textContent = 'Loaded';
|
||||
const viewer = data.viewer || 'user';
|
||||
renderLinks(data.links || {});
|
||||
setScopeVisibility(viewer);
|
||||
renderScope('user', data.user);
|
||||
if (data.slug_admin) renderScope('slug', data.slug_admin);
|
||||
if (data.platform_admin) renderScope('platform', data.platform_admin);
|
||||
status.textContent = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd';
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
status.textContent = 'Network error';
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
90
examples/crash_reporter/backend/scripts/test_graphs_api.sh
Executable file
90
examples/crash_reporter/backend/scripts/test_graphs_api.sh
Executable file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke-test graph session ingest + dashboard API.
|
||||
#
|
||||
# ./scripts/test_graphs_api.sh
|
||||
# GRAPHS_BASE=http://localhost:8080/app/androidcast_project/graphs CRASH_USER=admin CRASH_PASS=admin ./scripts/test_graphs_api.sh
|
||||
# COOKIE='ac_crash_sess=…' GRAPHS_BASE=https://apps.f0xx.org/app/androidcast_project/graphs ./scripts/test_graphs_api.sh
|
||||
set -euo pipefail
|
||||
|
||||
CRASH_BASE="${CRASH_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
|
||||
GRAPHS_BASE="${GRAPHS_BASE:-${CRASH_BASE/\/crashes\//\/graphs\/}}"
|
||||
UPLOAD_URL="${GRAPHS_BASE}/api/graph_upload.php"
|
||||
API_URL="${GRAPHS_BASE}/api/graphs.php"
|
||||
COOKIE_JAR="${TMPDIR:-/tmp}/graph_api_cookies_$$.txt"
|
||||
trap 'rm -f "$COOKIE_JAR"' EXIT
|
||||
|
||||
CURL_AUTH=()
|
||||
if [[ -n "${COOKIE:-}" ]]; then
|
||||
CURL_AUTH=(-H "Cookie: ${COOKIE}")
|
||||
else
|
||||
user="${CRASH_USER:-admin}"
|
||||
pass="${CRASH_PASS:-admin}"
|
||||
curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \
|
||||
-d "username=${user}&password=${pass}" \
|
||||
"${CRASH_BASE}/login" -o /dev/null
|
||||
CURL_AUTH=(-b "$COOKIE_JAR")
|
||||
fi
|
||||
|
||||
now_ms="$(date +%s)000"
|
||||
start_ms=$((now_ms - 600000))
|
||||
sid="smoke-graph-$(date +%s)"
|
||||
|
||||
payload="$(cat <<EOF
|
||||
{
|
||||
"schema_version": 1,
|
||||
"ingest_kind": "graph_session",
|
||||
"session_id": "${sid}",
|
||||
"company_id": 1,
|
||||
"user_id": 1,
|
||||
"device_id": "smoke-device-01",
|
||||
"app_version": "0.1.0-smoke",
|
||||
"sdk_int": 34,
|
||||
"started_at_epoch_ms": ${start_ms},
|
||||
"ended_at_epoch_ms": ${now_ms},
|
||||
"duration_s": 600,
|
||||
"completed": true,
|
||||
"direction": "sender",
|
||||
"transport": "wifi",
|
||||
"avg_kbps": 4200,
|
||||
"recv_kbps": 800,
|
||||
"packet_loss_pct": 0.4,
|
||||
"ntp_source": "backend",
|
||||
"ntp_correction_s": 2,
|
||||
"install_source": "ota",
|
||||
"meta": {"smoke": true}
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
|
||||
echo "POST ${UPLOAD_URL}"
|
||||
upload_code="$(curl -sS -o /tmp/graph_upload_resp.json -w '%{http_code}' \
|
||||
"${CURL_AUTH[@]}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "${payload}" \
|
||||
"${UPLOAD_URL}")"
|
||||
echo "upload HTTP ${upload_code}"
|
||||
cat /tmp/graph_upload_resp.json
|
||||
echo
|
||||
|
||||
echo "GET ${API_URL}?days=7"
|
||||
graphs_code="$(curl -sS -o /tmp/graphs_resp.json -w '%{http_code}' \
|
||||
"${CURL_AUTH[@]}" \
|
||||
"${API_URL}?days=7")"
|
||||
echo "graphs HTTP ${graphs_code}"
|
||||
head -c 400 /tmp/graphs_resp.json
|
||||
echo
|
||||
echo "…"
|
||||
|
||||
if [[ "${upload_code}" != "200" ]]; then
|
||||
echo "FAIL: upload expected 200" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${graphs_code}" != "200" ]]; then
|
||||
echo "FAIL: graphs API expected 200 (got ${graphs_code})" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q '"ok":true' /tmp/graphs_resp.json; then
|
||||
echo "FAIL: graphs API body missing ok:true" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "OK"
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -4,9 +4,14 @@ declare(strict_types=1);
|
||||
final class GraphRepository {
|
||||
public static function ensureSchema(): void {
|
||||
$pdo = Database::pdo();
|
||||
if (Database::tableExists($pdo, 'graph_sessions')) {
|
||||
if (!Database::tableExists($pdo, 'graph_sessions')) {
|
||||
self::createTable($pdo);
|
||||
return;
|
||||
}
|
||||
self::ensureColumns($pdo);
|
||||
}
|
||||
|
||||
private static function createTable(PDO $pdo): void {
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE IF NOT EXISTS graph_sessions (
|
||||
@@ -14,19 +19,27 @@ final class GraphRepository {
|
||||
session_id VARCHAR(96) NOT NULL,
|
||||
company_id INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
user_id INT UNSIGNED NULL,
|
||||
device_id VARCHAR(128) NULL,
|
||||
app_version VARCHAR(64) NULL,
|
||||
sdk_int INT NULL,
|
||||
started_at_ms BIGINT NOT NULL,
|
||||
ended_at_ms BIGINT NOT NULL,
|
||||
duration_s INT NOT NULL,
|
||||
completed TINYINT(1) NOT NULL DEFAULT 1,
|
||||
direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender',
|
||||
transport VARCHAR(24) NOT NULL DEFAULT 'wifi',
|
||||
avg_kbps INT NOT NULL DEFAULT 0,
|
||||
recv_kbps INT NOT NULL DEFAULT 0,
|
||||
packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0,
|
||||
ntp_source VARCHAR(32) NOT NULL DEFAULT 'device',
|
||||
ntp_correction_s INT NULL,
|
||||
install_source VARCHAR(32) NOT NULL DEFAULT 'direct',
|
||||
meta_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_graph_session_id (session_id),
|
||||
KEY idx_graph_company_time (company_id, started_at_ms)
|
||||
KEY idx_graph_company_time (company_id, started_at_ms),
|
||||
KEY idx_graph_device (device_id),
|
||||
KEY idx_graph_app_version (app_version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
return;
|
||||
@@ -37,14 +50,20 @@ final class GraphRepository {
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
company_id INTEGER NOT NULL DEFAULT 1,
|
||||
user_id INTEGER NULL,
|
||||
device_id TEXT NULL,
|
||||
app_version TEXT NULL,
|
||||
sdk_int INTEGER NULL,
|
||||
started_at_ms INTEGER NOT NULL,
|
||||
ended_at_ms INTEGER NOT NULL,
|
||||
duration_s INTEGER NOT NULL,
|
||||
completed INTEGER NOT NULL DEFAULT 1,
|
||||
direction TEXT NOT NULL DEFAULT 'sender',
|
||||
transport TEXT NOT NULL DEFAULT 'wifi',
|
||||
avg_kbps INTEGER NOT NULL DEFAULT 0,
|
||||
recv_kbps INTEGER NOT NULL DEFAULT 0,
|
||||
packet_loss_pct REAL NOT NULL DEFAULT 0.0,
|
||||
ntp_source TEXT NOT NULL DEFAULT 'device',
|
||||
ntp_correction_s INTEGER NULL,
|
||||
install_source TEXT NOT NULL DEFAULT 'direct',
|
||||
meta_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
@@ -52,6 +71,27 @@ final class GraphRepository {
|
||||
);
|
||||
}
|
||||
|
||||
private static function ensureColumns(PDO $pdo): void {
|
||||
$cols = [
|
||||
'device_id' => Database::isMysql() ? 'VARCHAR(128) NULL' : 'TEXT NULL',
|
||||
'app_version' => Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL',
|
||||
'sdk_int' => Database::isMysql() ? 'INT NULL' : 'INTEGER NULL',
|
||||
'completed' => Database::isMysql() ? 'TINYINT(1) NOT NULL DEFAULT 1' : 'INTEGER NOT NULL DEFAULT 1',
|
||||
'recv_kbps' => Database::isMysql() ? 'INT NOT NULL DEFAULT 0' : 'INTEGER NOT NULL DEFAULT 0',
|
||||
'ntp_correction_s' => Database::isMysql() ? 'INT NULL' : 'INTEGER NULL',
|
||||
];
|
||||
foreach ($cols as $name => $ddl) {
|
||||
try {
|
||||
$pdo->exec('ALTER TABLE graph_sessions ADD COLUMN ' . $name . ' ' . $ddl);
|
||||
} catch (PDOException $e) {
|
||||
$msg = strtolower($e->getMessage());
|
||||
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function insertSession(array $in): void {
|
||||
self::ensureSchema();
|
||||
$pdo = Database::pdo();
|
||||
@@ -72,7 +112,7 @@ final class GraphRepository {
|
||||
if ($transport === '') {
|
||||
$transport = 'wifi';
|
||||
}
|
||||
$ntp = strtolower(trim((string) ($in['ntp_source'] ?? 'device')));
|
||||
$ntp = strtolower(trim((string) ($in['ntp_source'] ?? ($in['time_source'] ?? 'device'))));
|
||||
if ($ntp === '') {
|
||||
$ntp = 'device';
|
||||
}
|
||||
@@ -82,6 +122,7 @@ final class GraphRepository {
|
||||
}
|
||||
$duration = max(0, (int) ($in['duration_s'] ?? (int) (($end - $start) / 1000)));
|
||||
$avgKbps = max(0, (int) ($in['avg_kbps'] ?? 0));
|
||||
$recvKbps = max(0, (int) ($in['recv_kbps'] ?? 0));
|
||||
$loss = max(0.0, (float) ($in['packet_loss_pct'] ?? 0.0));
|
||||
$companyId = (int) ($in['company_id'] ?? Rbac::defaultCompanyId());
|
||||
if ($companyId <= 0) {
|
||||
@@ -91,39 +132,75 @@ final class GraphRepository {
|
||||
if ($userId !== null && $userId <= 0) {
|
||||
$userId = null;
|
||||
}
|
||||
$deviceId = trim((string) ($in['device_id'] ?? ''));
|
||||
if ($deviceId === '' && is_array($in['device'] ?? null)) {
|
||||
$deviceId = trim((string) (($in['device']['id'] ?? $in['device']['device_id'] ?? '')));
|
||||
}
|
||||
$appVersion = trim((string) ($in['app_version'] ?? ''));
|
||||
if ($appVersion === '' && is_array($in['app'] ?? null)) {
|
||||
$appVersion = trim((string) ($in['app']['version_name'] ?? $in['app']['version'] ?? ''));
|
||||
}
|
||||
$sdkInt = isset($in['sdk_int']) ? (int) $in['sdk_int'] : null;
|
||||
if ($sdkInt === null && is_array($in['device'] ?? null)) {
|
||||
$sdkInt = isset($in['device']['sdk_int']) ? (int) $in['device']['sdk_int'] : null;
|
||||
}
|
||||
$completed = 1;
|
||||
if (array_key_exists('completed', $in)) {
|
||||
$completed = !empty($in['completed']) ? 1 : 0;
|
||||
}
|
||||
$ntpCorrection = isset($in['ntp_correction_s']) ? (int) $in['ntp_correction_s'] : null;
|
||||
if ($ntpCorrection === null && isset($in['ntp_correction_seconds'])) {
|
||||
$ntpCorrection = (int) $in['ntp_correction_seconds'];
|
||||
}
|
||||
$meta = $in['meta'] ?? null;
|
||||
$metaJson = is_array($meta) ? safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null;
|
||||
|
||||
if (Database::isMysql()) {
|
||||
$sql = 'INSERT INTO graph_sessions
|
||||
(session_id, company_id, user_id, started_at_ms, ended_at_ms, duration_s, direction, transport, avg_kbps, packet_loss_pct, ntp_source, install_source, meta_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(session_id, company_id, user_id, device_id, app_version, sdk_int,
|
||||
started_at_ms, ended_at_ms, duration_s, completed, direction, transport,
|
||||
avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
ended_at_ms = VALUES(ended_at_ms),
|
||||
duration_s = VALUES(duration_s),
|
||||
completed = VALUES(completed),
|
||||
avg_kbps = VALUES(avg_kbps),
|
||||
recv_kbps = VALUES(recv_kbps),
|
||||
packet_loss_pct = VALUES(packet_loss_pct),
|
||||
ntp_source = VALUES(ntp_source),
|
||||
ntp_correction_s = VALUES(ntp_correction_s),
|
||||
install_source = VALUES(install_source),
|
||||
device_id = COALESCE(VALUES(device_id), device_id),
|
||||
app_version = COALESCE(VALUES(app_version), app_version),
|
||||
sdk_int = COALESCE(VALUES(sdk_int), sdk_int),
|
||||
meta_json = VALUES(meta_json)';
|
||||
} else {
|
||||
$sql = 'INSERT OR REPLACE INTO graph_sessions
|
||||
(session_id, company_id, user_id, started_at_ms, ended_at_ms, duration_s, direction, transport, avg_kbps, packet_loss_pct, ntp_source, install_source, meta_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
||||
(session_id, company_id, user_id, device_id, app_version, sdk_int,
|
||||
started_at_ms, ended_at_ms, duration_s, completed, direction, transport,
|
||||
avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
||||
}
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
$sessionId,
|
||||
$companyId,
|
||||
$userId,
|
||||
$deviceId !== '' ? $deviceId : null,
|
||||
$appVersion !== '' ? $appVersion : null,
|
||||
$sdkInt,
|
||||
$start,
|
||||
$end,
|
||||
$duration,
|
||||
$completed,
|
||||
$dir,
|
||||
$transport,
|
||||
$avgKbps,
|
||||
$recvKbps,
|
||||
$loss,
|
||||
$ntp,
|
||||
$ntpCorrection,
|
||||
$install,
|
||||
$metaJson,
|
||||
]);
|
||||
@@ -131,79 +208,139 @@ final class GraphRepository {
|
||||
|
||||
public static function dashboardData(int $days = 14): array {
|
||||
self::ensureSchema();
|
||||
$days = max(3, min(60, $days));
|
||||
$days = max(1, min(60, $days));
|
||||
$user = Auth::user();
|
||||
$uid = (int) ($user['id'] ?? 0);
|
||||
$scopeUserId = $uid > 0 ? $uid : null;
|
||||
$activeCompanyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
|
||||
$viewer = self::viewerRole($user);
|
||||
|
||||
return [
|
||||
$payload = [
|
||||
'window_days' => $days,
|
||||
'user' => self::buildScope($days, $activeCompanyId, $scopeUserId),
|
||||
'slug_admin' => self::buildScope($days, $activeCompanyId, null),
|
||||
'platform_admin' => self::buildScope($days, null, null),
|
||||
'viewer' => $viewer,
|
||||
'links' => self::serviceLinks(),
|
||||
];
|
||||
$payload['user'] = self::buildScope($days, $activeCompanyId, $scopeUserId);
|
||||
if ($viewer === 'user') {
|
||||
return $payload;
|
||||
}
|
||||
$payload['slug_admin'] = self::buildScope($days, $activeCompanyId, null);
|
||||
if ($viewer !== 'platform_admin') {
|
||||
return $payload;
|
||||
}
|
||||
$payload['platform_admin'] = self::buildScope($days, null, null, true);
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private static function viewerRole(?array $user): string {
|
||||
if (Rbac::isGlobalAdmin($user)) {
|
||||
return 'platform_admin';
|
||||
}
|
||||
$companyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
|
||||
$uid = (int) ($user['id'] ?? 0);
|
||||
$role = ($companyId > 0 && $uid > 0) ? Rbac::companyRole($uid, $companyId) : null;
|
||||
if ($role === 'owner' || $role === 'admin') {
|
||||
return 'slug_admin';
|
||||
}
|
||||
return 'user';
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private static function serviceLinks(): array {
|
||||
$prefix = '/app/androidcast_project';
|
||||
return [
|
||||
'hub' => $prefix . '/',
|
||||
'graphs' => $prefix . '/graphs/',
|
||||
'crashes' => $prefix . '/crashes/?view=reports',
|
||||
'tickets' => $prefix . '/crashes/?view=tickets',
|
||||
'build' => $prefix . '/build/',
|
||||
'git' => $prefix . '/git/',
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildScope(int $days, ?int $companyId, ?int $userId): array {
|
||||
private static function buildScope(int $days, ?int $companyId, ?int $userId, bool $platform = false): array {
|
||||
$startMs = (int) ((time() - ($days * 86400)) * 1000);
|
||||
return [
|
||||
$scope = [
|
||||
'sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId),
|
||||
'unique_devices_per_day' => self::uniqueDevicesPerDay($startMs, $companyId, $userId),
|
||||
'avg_duration_s_per_day' => self::avgDurationPerDay($startMs, $companyId, $userId),
|
||||
'send_sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId, 'sender'),
|
||||
'recv_sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId, 'receiver'),
|
||||
'success_ratio_pct' => self::successRatio($startMs, $companyId, $userId),
|
||||
'avg_bitrate_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId, 'avg_kbps'),
|
||||
'avg_recv_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId, 'recv_kbps'),
|
||||
'crashes_per_day' => self::crashesPerDay($startMs, $companyId),
|
||||
'avg_bitrate_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId),
|
||||
'ntp_sources' => self::breakdown('ntp_source', $startMs, $companyId, $userId),
|
||||
'install_sources' => self::breakdown('install_source', $startMs, $companyId, $userId),
|
||||
'transport_mix' => self::breakdown('transport', $startMs, $companyId, $userId),
|
||||
'app_versions' => self::breakdown('app_version', $startMs, $companyId, $userId),
|
||||
];
|
||||
if (Database::tableExists(Database::pdo(), 'tickets')) {
|
||||
$scope['tickets_per_day'] = self::ticketsPerDay($startMs, $companyId);
|
||||
}
|
||||
if ($platform) {
|
||||
$scope['top_crash_fingerprints'] = self::topCrashFingerprints($startMs, 8);
|
||||
$scope['crashes_by_app_version'] = self::crashesByAppVersion($startMs);
|
||||
$scope['ntp_correction_avg_s'] = self::avgNtpCorrection($startMs, $companyId, $userId);
|
||||
}
|
||||
return $scope;
|
||||
}
|
||||
|
||||
private static function sessionsPerDay(int $startMs, ?int $companyId, ?int $userId): array {
|
||||
private static function sessionsPerDay(
|
||||
int $startMs,
|
||||
?int $companyId,
|
||||
?int $userId,
|
||||
?string $direction = null
|
||||
): array {
|
||||
$where = ['started_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
if ($direction !== null) {
|
||||
$where[] = 'direction = ?';
|
||||
$params[] = $direction;
|
||||
}
|
||||
self::appendScope($where, $params, $companyId, $userId);
|
||||
$sql = 'SELECT started_at_ms FROM graph_sessions WHERE ' . implode(' AND ', $where);
|
||||
return self::countByDay($sql, $params, 'started_at_ms');
|
||||
}
|
||||
|
||||
private static function uniqueDevicesPerDay(int $startMs, ?int $companyId, ?int $userId): array {
|
||||
$where = ['started_at_ms >= ?', "device_id IS NOT NULL", "device_id <> ''"];
|
||||
$params = [$startMs];
|
||||
self::appendScope($where, $params, $companyId, $userId);
|
||||
$sql = 'SELECT started_at_ms, device_id FROM graph_sessions WHERE ' . implode(' AND ', $where);
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$bucket = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$day = gmdate('Y-m-d', intdiv((int) ($row['started_at_ms'] ?? 0), 1000));
|
||||
$dev = (string) ($row['device_id'] ?? '');
|
||||
if ($dev === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($bucket[$day])) {
|
||||
$bucket[$day] = [];
|
||||
}
|
||||
$bucket[$day][$dev] = true;
|
||||
}
|
||||
$out = [];
|
||||
foreach ($bucket as $day => $set) {
|
||||
$out[$day] = count($set);
|
||||
}
|
||||
return self::assocToSeries($out);
|
||||
}
|
||||
|
||||
private static function avgDurationPerDay(int $startMs, ?int $companyId, ?int $userId): array {
|
||||
$where = ['started_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
self::appendScope($where, $params, $companyId, $userId);
|
||||
$sql = 'SELECT started_at_ms FROM graph_sessions WHERE ' . implode(' AND ', $where) . ' ORDER BY started_at_ms ASC';
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$bucket = [];
|
||||
foreach ($rows as $row) {
|
||||
$day = gmdate('Y-m-d', ((int) ($row['started_at_ms'] ?? 0)) / 1000);
|
||||
$bucket[$day] = ($bucket[$day] ?? 0) + 1;
|
||||
}
|
||||
return self::assocToSeries($bucket);
|
||||
}
|
||||
|
||||
private static function crashesPerDay(int $startMs, ?int $companyId): array {
|
||||
$where = ['received_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
if ($companyId !== null && $companyId > 0) {
|
||||
$where[] = 'company_id = ?';
|
||||
$params[] = $companyId;
|
||||
}
|
||||
$sql = 'SELECT received_at_ms FROM reports WHERE ' . implode(' AND ', $where) . ' ORDER BY received_at_ms ASC';
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$bucket = [];
|
||||
foreach ($rows as $row) {
|
||||
$day = gmdate('Y-m-d', ((int) ($row['received_at_ms'] ?? 0)) / 1000);
|
||||
$bucket[$day] = ($bucket[$day] ?? 0) + 1;
|
||||
}
|
||||
return self::assocToSeries($bucket);
|
||||
}
|
||||
|
||||
private static function avgBitratePerDay(int $startMs, ?int $companyId, ?int $userId): array {
|
||||
$where = ['started_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
self::appendScope($where, $params, $companyId, $userId);
|
||||
$sql = 'SELECT started_at_ms, avg_kbps FROM graph_sessions WHERE ' . implode(' AND ', $where) . ' ORDER BY started_at_ms ASC';
|
||||
$sql = 'SELECT started_at_ms, duration_s FROM graph_sessions WHERE ' . implode(' AND ', $where);
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$sum = [];
|
||||
$cnt = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$day = gmdate('Y-m-d', ((int) ($row['started_at_ms'] ?? 0)) / 1000);
|
||||
$sum[$day] = ($sum[$day] ?? 0.0) + (float) ($row['avg_kbps'] ?? 0);
|
||||
$day = gmdate('Y-m-d', intdiv((int) ($row['started_at_ms'] ?? 0), 1000));
|
||||
$sum[$day] = ($sum[$day] ?? 0) + (int) ($row['duration_s'] ?? 0);
|
||||
$cnt[$day] = ($cnt[$day] ?? 0) + 1;
|
||||
}
|
||||
$avg = [];
|
||||
@@ -213,25 +350,169 @@ final class GraphRepository {
|
||||
return self::assocToSeries($avg);
|
||||
}
|
||||
|
||||
private static function breakdown(string $column, int $startMs, ?int $companyId, ?int $userId): array {
|
||||
$allowed = ['ntp_source', 'install_source', 'transport'];
|
||||
private static function successRatio(int $startMs, ?int $companyId, ?int $userId): float {
|
||||
$where = ['started_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
self::appendScope($where, $params, $companyId, $userId);
|
||||
$sql = 'SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN completed = 1 THEN 1 ELSE 0 END) AS ok
|
||||
FROM graph_sessions WHERE ' . implode(' AND ', $where);
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
$total = (int) ($row['total'] ?? 0);
|
||||
$ok = (int) ($row['ok'] ?? 0);
|
||||
if ($total <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
return round(100.0 * $ok / $total, 1);
|
||||
}
|
||||
|
||||
private static function avgBitratePerDay(
|
||||
int $startMs,
|
||||
?int $companyId,
|
||||
?int $userId,
|
||||
string $column
|
||||
): array {
|
||||
$allowed = ['avg_kbps', 'recv_kbps'];
|
||||
if (!in_array($column, $allowed, true)) {
|
||||
return [];
|
||||
}
|
||||
$where = ['started_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
self::appendScope($where, $params, $companyId, $userId);
|
||||
$sql = 'SELECT started_at_ms, ' . $column . ' AS v FROM graph_sessions WHERE '
|
||||
. implode(' AND ', $where);
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$sum = [];
|
||||
$cnt = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$day = gmdate('Y-m-d', intdiv((int) ($row['started_at_ms'] ?? 0), 1000));
|
||||
$sum[$day] = ($sum[$day] ?? 0.0) + (float) ($row['v'] ?? 0);
|
||||
$cnt[$day] = ($cnt[$day] ?? 0) + 1;
|
||||
}
|
||||
$avg = [];
|
||||
foreach ($sum as $day => $total) {
|
||||
$avg[$day] = $cnt[$day] > 0 ? round($total / $cnt[$day], 1) : 0.0;
|
||||
}
|
||||
return self::assocToSeries($avg);
|
||||
}
|
||||
|
||||
private static function crashesPerDay(int $startMs, ?int $companyId): array {
|
||||
if (!Database::tableExists(Database::pdo(), 'reports')) {
|
||||
return [];
|
||||
}
|
||||
$where = ['received_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
if ($companyId !== null && $companyId > 0) {
|
||||
$where[] = 'company_id = ?';
|
||||
$params[] = $companyId;
|
||||
}
|
||||
$sql = 'SELECT received_at_ms FROM reports WHERE ' . implode(' AND ', $where);
|
||||
return self::countByDay($sql, $params, 'received_at_ms');
|
||||
}
|
||||
|
||||
private static function ticketsPerDay(int $startMs, ?int $companyId): array {
|
||||
$where = ['opened_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
if ($companyId !== null && $companyId > 0) {
|
||||
$where[] = 'company_id = ?';
|
||||
$params[] = $companyId;
|
||||
}
|
||||
$sql = 'SELECT opened_at_ms FROM tickets WHERE ' . implode(' AND ', $where);
|
||||
return self::countByDay($sql, $params, 'opened_at_ms');
|
||||
}
|
||||
|
||||
private static function topCrashFingerprints(int $startMs, int $limit): array {
|
||||
if (!Database::tableExists(Database::pdo(), 'reports')) {
|
||||
return [];
|
||||
}
|
||||
$sql = 'SELECT fingerprint, COUNT(*) AS c, MAX(id) AS last_id
|
||||
FROM reports WHERE received_at_ms >= ?
|
||||
GROUP BY fingerprint ORDER BY c DESC LIMIT ' . (int) $limit;
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute([$startMs]);
|
||||
$base = Auth::basePath();
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$fp = (string) ($row['fingerprint'] ?? '');
|
||||
$id = (int) ($row['last_id'] ?? 0);
|
||||
$out[] = [
|
||||
'label' => $fp,
|
||||
'value' => (int) ($row['c'] ?? 0),
|
||||
'href' => $id > 0 ? $base . '/?view=report&id=' . $id : null,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function crashesByAppVersion(int $startMs): array {
|
||||
if (!Database::tableExists(Database::pdo(), 'reports')) {
|
||||
return [];
|
||||
}
|
||||
$sql = "SELECT COALESCE(NULLIF(app_version, ''), 'unknown') AS k, COUNT(*) AS c
|
||||
FROM reports WHERE received_at_ms >= ?
|
||||
GROUP BY k ORDER BY c DESC LIMIT 12";
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute([$startMs]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function avgNtpCorrection(int $startMs, ?int $companyId, ?int $userId): ?float {
|
||||
$where = ['started_at_ms >= ?', 'ntp_correction_s IS NOT NULL'];
|
||||
$params = [$startMs];
|
||||
self::appendScope($where, $params, $companyId, $userId);
|
||||
$sql = 'SELECT AVG(ABS(ntp_correction_s)) AS avg_s FROM graph_sessions WHERE '
|
||||
. implode(' AND ', $where);
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$v = $stmt->fetchColumn();
|
||||
return $v === false || $v === null ? null : round((float) $v, 2);
|
||||
}
|
||||
|
||||
private static function breakdown(string $column, int $startMs, ?int $companyId, ?int $userId): array {
|
||||
$allowed = ['ntp_source', 'install_source', 'transport', 'app_version'];
|
||||
if (!in_array($column, $allowed, true)) {
|
||||
return [];
|
||||
}
|
||||
$where = ['started_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
if ($column === 'app_version') {
|
||||
$where[] = "app_version IS NOT NULL";
|
||||
$where[] = "app_version <> ''";
|
||||
}
|
||||
self::appendScope($where, $params, $companyId, $userId);
|
||||
$sql = 'SELECT ' . $column . ' AS k, COUNT(*) AS c FROM graph_sessions WHERE '
|
||||
. implode(' AND ', $where) . ' GROUP BY ' . $column . ' ORDER BY c DESC';
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
|
||||
$label = (string) ($row['k'] ?? 'unknown');
|
||||
if ($label === '') {
|
||||
$label = 'unknown';
|
||||
}
|
||||
$out[] = ['label' => $label, 'value' => (int) ($row['c'] ?? 0)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function countByDay(string $sql, array $params, string $msColumn): array {
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$bucket = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$day = gmdate('Y-m-d', intdiv((int) ($row[$msColumn] ?? 0), 1000));
|
||||
$bucket[$day] = ($bucket[$day] ?? 0) + 1;
|
||||
}
|
||||
return self::assocToSeries($bucket);
|
||||
}
|
||||
|
||||
private static function appendScope(array &$where, array &$params, ?int $companyId, ?int $userId): void {
|
||||
if ($companyId !== null && $companyId > 0) {
|
||||
$where[] = 'company_id = ?';
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
<nav class="reports-pagination" id="reports-pagination" aria-label="Reports pages"></nav>
|
||||
</div>
|
||||
<?php elseif (($view ?? '') === 'graphs'): ?>
|
||||
<div id="graphs-app" class="reports-app">
|
||||
<div id="graphs-app" class="reports-app graphs-app">
|
||||
<div class="toolbar reports-toolbar">
|
||||
<h1>AndroidCast graphs</h1>
|
||||
<div class="toolbar-actions">
|
||||
@@ -249,6 +249,7 @@
|
||||
<label class="toolbar-select">
|
||||
<span>Window</span>
|
||||
<select id="graphs-days" aria-label="Graphs window">
|
||||
<option value="1">1d</option>
|
||||
<option value="7">7d</option>
|
||||
<option value="14" selected>14d</option>
|
||||
<option value="30">30d</option>
|
||||
@@ -256,36 +257,56 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<nav id="graphs-quick-links" class="graphs-quick-links" aria-label="Related services"></nav>
|
||||
<p id="graphs-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||
<div class="cards cards--lift">
|
||||
<article class="card card--lift">
|
||||
<h3>User scope</h3>
|
||||
<canvas id="graph-user-sessions" width="560" height="180"></canvas>
|
||||
</article>
|
||||
<article class="card card--lift">
|
||||
<h3>Slug-admin scope</h3>
|
||||
<canvas id="graph-slug-sessions" width="560" height="180"></canvas>
|
||||
</article>
|
||||
<article class="card card--lift">
|
||||
<h3>Platform scope</h3>
|
||||
<canvas id="graph-platform-sessions" width="560" height="180"></canvas>
|
||||
</article>
|
||||
<article class="card card--lift">
|
||||
<h3>Bitrate (kbps)</h3>
|
||||
<canvas id="graph-bitrate" width="560" height="180"></canvas>
|
||||
</article>
|
||||
<article class="card card--lift">
|
||||
<h3>NTP source mix</h3>
|
||||
<div id="graph-ntp-breakdown" class="muted">—</div>
|
||||
</article>
|
||||
<article class="card card--lift">
|
||||
<h3>Install source mix</h3>
|
||||
<div id="graph-install-breakdown" class="muted">—</div>
|
||||
</article>
|
||||
</div>
|
||||
<p class="muted">
|
||||
APIs: <code><?= h(Auth::basePath()) ?>/api/graphs.php</code> and
|
||||
|
||||
<section class="graphs-scope" data-graph-scope="user">
|
||||
<h2 class="graphs-scope-title">Your activity</h2>
|
||||
<div class="cards cards--lift graphs-grid">
|
||||
<article class="card card--lift"><h3>Sessions / day</h3><canvas id="graph-user-sessions" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Unique devices / day</h3><canvas id="graph-user-devices" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Avg duration (s) / day</h3><canvas id="graph-user-duration" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Send sessions / day</h3><canvas id="graph-user-send" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Receive sessions / day</h3><canvas id="graph-user-recv" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Success ratio</h3><p id="graph-user-success" class="graph-kpi">—</p></article>
|
||||
<article class="card card--lift"><h3>Outbound kbps / day</h3><canvas id="graph-user-bitrate" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Inbound kbps / day</h3><canvas id="graph-user-recv-kbps" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Crashes / day</h3><canvas id="graph-user-crashes" width="520" height="160"></canvas></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="graphs-scope" data-graph-scope="slug_admin" hidden>
|
||||
<h2 class="graphs-scope-title">Company (slug admin)</h2>
|
||||
<div class="cards cards--lift graphs-grid">
|
||||
<article class="card card--lift"><h3>Sessions / day</h3><canvas id="graph-slug-sessions" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Unique devices / day</h3><canvas id="graph-slug-devices" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Install sources</h3><canvas id="graph-slug-install-pie" width="520" height="180"></canvas></article>
|
||||
<article class="card card--lift"><h3>NTP sources</h3><canvas id="graph-slug-ntp-pie" width="520" height="180"></canvas></article>
|
||||
<article class="card card--lift"><h3>Transport mix</h3><div id="graph-slug-transport" class="graph-breakdown"></div></article>
|
||||
<article class="card card--lift"><h3>App versions</h3><div id="graph-slug-versions" class="graph-breakdown"></div></article>
|
||||
<article class="card card--lift"><h3>Crashes / day</h3><canvas id="graph-slug-crashes" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Tickets / day</h3><canvas id="graph-slug-tickets" width="520" height="160"></canvas></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="graphs-scope" data-graph-scope="platform_admin" hidden>
|
||||
<h2 class="graphs-scope-title">Platform reliability</h2>
|
||||
<div class="cards cards--lift graphs-grid">
|
||||
<article class="card card--lift"><h3>Sessions / day (all slugs)</h3><canvas id="graph-platform-sessions" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Crashes / day</h3><canvas id="graph-platform-crashes" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Tickets / day</h3><canvas id="graph-platform-tickets" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Install sources</h3><canvas id="graph-platform-install-pie" width="520" height="180"></canvas></article>
|
||||
<article class="card card--lift"><h3>NTP correction</h3><p id="graph-platform-ntp-avg" class="graph-kpi muted">—</p></article>
|
||||
<article class="card card--lift"><h3>Top crash fingerprints</h3><div id="graph-platform-fingerprints" class="graph-breakdown"></div></article>
|
||||
<article class="card card--lift"><h3>Crashes by app version</h3><canvas id="graph-platform-crash-versions" width="520" height="180"></canvas></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="muted graphs-footnote">
|
||||
Direct DB metrics (no ETL). Ingest:
|
||||
<code><?= h(Auth::basePath()) ?>/api/graph_upload.php</code>
|
||||
· Read:
|
||||
<code><?= h(Auth::basePath()) ?>/api/graphs.php</code>
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
orchestration/runtime/db-init/070_graph_ecosystem.sql
Normal file
28
orchestration/runtime/db-init/070_graph_ecosystem.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
USE `androidcast_crashes`;
|
||||
CREATE TABLE IF NOT EXISTS graph_sessions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(96) NOT NULL,
|
||||
company_id INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
user_id INT UNSIGNED NULL,
|
||||
device_id VARCHAR(128) NULL,
|
||||
app_version VARCHAR(64) NULL,
|
||||
sdk_int INT NULL,
|
||||
started_at_ms BIGINT NOT NULL,
|
||||
ended_at_ms BIGINT NOT NULL,
|
||||
duration_s INT NOT NULL,
|
||||
completed TINYINT(1) NOT NULL DEFAULT 1,
|
||||
direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender',
|
||||
transport VARCHAR(24) NOT NULL DEFAULT 'wifi',
|
||||
avg_kbps INT NOT NULL DEFAULT 0,
|
||||
recv_kbps INT NOT NULL DEFAULT 0,
|
||||
packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0,
|
||||
ntp_source VARCHAR(32) NOT NULL DEFAULT 'device',
|
||||
ntp_correction_s INT NULL,
|
||||
install_source VARCHAR(32) NOT NULL DEFAULT 'direct',
|
||||
meta_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_graph_session_id (session_id),
|
||||
KEY idx_graph_company_time (company_id, started_at_ms),
|
||||
KEY idx_graph_device (device_id),
|
||||
KEY idx_graph_app_version (app_version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
Reference in New Issue
Block a user