mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:39:09 +03:00
Graphs dashboard: role-scoped metrics, orchestration, and smoke tests.
Extend graph_sessions schema and GraphRepository dashboards (user/slug/platform), wire nginx/orchestration routes, fix gmdate timestamps, and add API smoke script. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -322,6 +322,25 @@
|
|||||||
box-shadow: 0 -4px 0 currentColor, 0 4px 0 currentColor;
|
box-shadow: 0 -4px 0 currentColor, 0 4px 0 currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-icon--graphs {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-left: 2px solid currentColor;
|
||||||
|
border-bottom: 2px solid currentColor;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.nav-icon--graphs::before,
|
||||||
|
.nav-icon--graphs::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: 1px 1px 0 0;
|
||||||
|
}
|
||||||
|
.nav-icon--graphs::before { left: 4px; height: 8px; }
|
||||||
|
.nav-icon--graphs::after { left: 10px; height: 12px; }
|
||||||
|
|
||||||
.hub-page .bottom-bar {
|
.hub-page .bottom-bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|||||||
@@ -213,28 +213,45 @@ MariaDB (**required on existing servers**): `mysql -u root -p androidcast_crashe
|
|||||||
|
|
||||||
## Graphs dashboard (`/graphs`)
|
## Graphs dashboard (`/graphs`)
|
||||||
|
|
||||||
Stage-1 custom BE graphs are available at:
|
Custom Grafana-style BE dashboards (direct MariaDB reads, no ETL):
|
||||||
|
|
||||||
- `https://apps.f0xx.org/app/androidcast_project/graphs/`
|
- `https://apps.f0xx.org/app/androidcast_project/graphs/`
|
||||||
- Uses the same auth/session, users, RBAC, and DB as crashes/tickets.
|
- Same PHP session/RBAC as crashes, tickets, and builder (`session_cookie_path` `/app/androidcast_project`).
|
||||||
- Hub card: `AndroidCast graphs` (between crashes and builder).
|
- Hub card: **AndroidCast graphs** (quick links to crashes, tickets, builder, git).
|
||||||
|
|
||||||
|
**MariaDB (existing server):** run once as root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mysql -u root -p androidcast_crashes < sql/migrations/006_graph_sessions.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Table auto-creates on first ingest if missing (SQLite dev); production MariaDB should use migration **006**.
|
||||||
|
|
||||||
APIs:
|
APIs:
|
||||||
|
|
||||||
- `GET /app/androidcast_project/graphs/api/graphs.php?days=14` (dashboard data in user/slug_admin/platform scopes)
|
- `GET …/graphs/api/graphs.php?days=7` — role-scoped metrics (`user`, `slug_admin`, `platform_admin`)
|
||||||
- `POST /app/androidcast_project/graphs/api/graph_upload.php` (ingest `graph_session` payloads)
|
- `POST …/graphs/api/graph_upload.php` — device/session ingest (`ingest_kind=graph_session`, no session cookie)
|
||||||
|
|
||||||
Fake data helper scripts:
|
Smoke test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/test_graphs_api.sh
|
||||||
|
BASE=http://localhost:8082/app/androidcast_project/crashes ./scripts/test_graphs_api.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Fake data:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd examples/crash_reporter/backend
|
|
||||||
php scripts/generate_fake_graph_sessions.php --count=240 --days=30
|
php scripts/generate_fake_graph_sessions.php --count=240 --days=30
|
||||||
php scripts/upload_fake_graph_sessions.php --url=https://apps.f0xx.org/app/androidcast_project/graphs
|
php scripts/upload_fake_graph_sessions.php --url=https://apps.f0xx.org/app/androidcast_project/graphs
|
||||||
```
|
```
|
||||||
|
|
||||||
Payload shape (`schema_version=1`, `ingest_kind=graph_session`) includes:
|
Payload (`schema_version=1`): `session_id`, `company_id`, `user_id`, `device_id`, `app_version`,
|
||||||
`session_id`, `company_id`, `user_id`, `started_at_epoch_ms`, `ended_at_epoch_ms`, `duration_s`,
|
`sdk_int`, timing, `completed`, `direction`, `transport`, `avg_kbps`, `recv_kbps`,
|
||||||
`direction`, `transport`, `avg_kbps`, `packet_loss_pct`, `ntp_source`, `install_source`, `meta`.
|
`packet_loss_pct`, `ntp_source`, `ntp_correction_s`, `install_source`, optional `meta`.
|
||||||
|
|
||||||
|
NTP heartbeat (`POST …/api/heartbeat.php`, `type=ntp`) returns `time_source` and `ntp_correction_s`
|
||||||
|
for clients that sync clocks before upload.
|
||||||
|
|
||||||
## Default accounts
|
## Default accounts
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ server {
|
|||||||
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
|
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ^~ /app/androidcast_project/graphs/assets/ {
|
||||||
|
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
|
||||||
|
}
|
||||||
|
|
||||||
location = /app/androidcast_project/crashes/api/upload.php {
|
location = /app/androidcast_project/crashes/api/upload.php {
|
||||||
include fastcgi_params;
|
include fastcgi_params;
|
||||||
fastcgi_pass unix:/run/php-fpm.socket;
|
fastcgi_pass unix:/run/php-fpm.socket;
|
||||||
|
|||||||
@@ -38,9 +38,16 @@ $resp = [
|
|||||||
if ($type === 'ntp') {
|
if ($type === 'ntp') {
|
||||||
$clientDate = (int)($heartbeat['date'] ?? 0);
|
$clientDate = (int)($heartbeat['date'] ?? 0);
|
||||||
$clientTz = (string)($heartbeat['tz'] ?? '');
|
$clientTz = (string)($heartbeat['tz'] ?? '');
|
||||||
|
$timeSource = trim((string)($heartbeat['time_source'] ?? 'device'));
|
||||||
|
if ($timeSource === '') {
|
||||||
|
$timeSource = 'device';
|
||||||
|
}
|
||||||
|
$correction = $clientDate > 0 ? ($srvEpoch - $clientDate) : null;
|
||||||
$resp['heartbeat']['client_date'] = $clientDate;
|
$resp['heartbeat']['client_date'] = $clientDate;
|
||||||
$resp['heartbeat']['client_tz'] = $clientTz;
|
$resp['heartbeat']['client_tz'] = $clientTz;
|
||||||
$resp['heartbeat']['correction_seconds'] = $clientDate > 0 ? ($srvEpoch - $clientDate) : null;
|
$resp['heartbeat']['time_source'] = $timeSource;
|
||||||
|
$resp['heartbeat']['correction_seconds'] = $correction;
|
||||||
|
$resp['heartbeat']['ntp_correction_s'] = $correction;
|
||||||
$resp['heartbeat']['enabled'] = true;
|
$resp['heartbeat']['enabled'] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1223,3 +1223,41 @@ a:hover { text-decoration: underline; }
|
|||||||
}
|
}
|
||||||
.tag-modal-close:hover { color: var(--text); background: var(--row-hover); }
|
.tag-modal-close:hover { color: var(--text); background: var(--row-hover); }
|
||||||
.tag-editor--modal { margin: 0; border: none; border-radius: 0; }
|
.tag-editor--modal { margin: 0; border: none; border-radius: 0; }
|
||||||
|
|
||||||
|
.graphs-app .graphs-quick-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.graph-quick-link { font-size: 0.9rem; }
|
||||||
|
.graphs-scope { margin-top: 20px; }
|
||||||
|
.graphs-scope-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.graphs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.graphs-grid canvas { width: 100%; height: auto; max-width: 100%; }
|
||||||
|
.graph-kpi {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--accent, #3d8bfd);
|
||||||
|
}
|
||||||
|
.graph-breakdown { font-size: 0.92rem; }
|
||||||
|
.graph-breakdown-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
}
|
||||||
|
.graph-breakdown-row a { color: var(--accent, #3d8bfd); text-decoration: none; }
|
||||||
|
.graph-breakdown-row a:hover { text-decoration: underline; }
|
||||||
|
.graphs-footnote { margin-top: 20px; font-size: 0.88rem; }
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
(function () {
|
(function () {
|
||||||
|
const COLORS = ['#3d8bfd', '#5eead4', '#f59e0b', '#34d399', '#a78bfa', '#f472b6', '#94a3b8'];
|
||||||
|
|
||||||
function basePath() {
|
function basePath() {
|
||||||
return document.body.getAttribute('data-base-path') || '';
|
return document.body.getAttribute('data-base-path') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function el(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
function drawLineChart(canvas, series, color) {
|
function drawLineChart(canvas, series, color) {
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
@@ -10,27 +16,35 @@
|
|||||||
const w = canvas.width;
|
const w = canvas.width;
|
||||||
const h = canvas.height;
|
const h = canvas.height;
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
ctx.fillStyle = 'rgba(90,110,140,0.15)';
|
ctx.fillStyle = 'rgba(90,110,140,0.12)';
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
const points = Array.isArray(series) ? series : [];
|
const points = Array.isArray(series) ? series : [];
|
||||||
if (!points.length) return;
|
if (!points.length) {
|
||||||
|
ctx.fillStyle = '#94a3b8';
|
||||||
|
ctx.font = '12px system-ui, sans-serif';
|
||||||
|
ctx.fillText('No data', 24, h / 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let maxY = 0;
|
let maxY = 0;
|
||||||
points.forEach((p) => {
|
points.forEach((p) => {
|
||||||
const y = Number(p.y || 0);
|
const y = Number(p.y || 0);
|
||||||
if (y > maxY) maxY = y;
|
if (y > maxY) maxY = y;
|
||||||
});
|
});
|
||||||
if (maxY <= 0) maxY = 1;
|
if (maxY <= 0) maxY = 1;
|
||||||
const pad = 20;
|
const padL = 36;
|
||||||
const plotW = w - pad * 2;
|
const padR = 12;
|
||||||
const plotH = h - pad * 2;
|
const padT = 12;
|
||||||
|
const padB = 28;
|
||||||
|
const plotW = w - padL - padR;
|
||||||
|
const plotH = h - padT - padB;
|
||||||
|
|
||||||
ctx.strokeStyle = 'rgba(120,140,170,0.5)';
|
ctx.strokeStyle = 'rgba(120,140,170,0.45)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
for (let i = 0; i <= 4; i++) {
|
for (let i = 0; i <= 4; i++) {
|
||||||
const y = pad + (plotH * i) / 4;
|
const y = padT + (plotH * i) / 4;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(pad, y);
|
ctx.moveTo(padL, y);
|
||||||
ctx.lineTo(w - pad, y);
|
ctx.lineTo(w - padR, y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,34 +52,194 @@
|
|||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
points.forEach((p, idx) => {
|
points.forEach((p, idx) => {
|
||||||
const x = pad + (plotW * idx) / Math.max(1, points.length - 1);
|
const x = padL + (plotW * idx) / Math.max(1, points.length - 1);
|
||||||
const y = pad + plotH - (plotH * Number(p.y || 0)) / maxY;
|
const y = padT + plotH - (plotH * Number(p.y || 0)) / maxY;
|
||||||
if (idx === 0) ctx.moveTo(x, y);
|
if (idx === 0) ctx.moveTo(x, y);
|
||||||
else ctx.lineTo(x, y);
|
else ctx.lineTo(x, y);
|
||||||
});
|
});
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#94a3b8';
|
||||||
|
ctx.font = '10px system-ui, sans-serif';
|
||||||
|
if (points.length) {
|
||||||
|
ctx.fillText(String(points[0].x || ''), padL, h - 8);
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
const lx = padL + plotW - 48;
|
||||||
|
ctx.fillText(String(last.x || ''), lx, h - 8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBreakdown(el, rows) {
|
function drawPie(canvas, rows) {
|
||||||
if (!el) return;
|
if (!canvas) return;
|
||||||
if (!Array.isArray(rows) || !rows.length) {
|
const ctx = canvas.getContext('2d');
|
||||||
el.textContent = 'No data';
|
if (!ctx) return;
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
const data = Array.isArray(rows) ? rows.filter((r) => Number(r.value) > 0) : [];
|
||||||
|
if (!data.length) {
|
||||||
|
ctx.fillStyle = '#94a3b8';
|
||||||
|
ctx.font = '12px system-ui, sans-serif';
|
||||||
|
ctx.fillText('No data', 24, h / 2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.innerHTML = rows
|
const total = data.reduce((s, r) => s + Number(r.value || 0), 0);
|
||||||
.map((r) => `<div><strong>${String(r.label || '')}</strong>: ${Number(r.value || 0)}</div>`)
|
const cx = w * 0.35;
|
||||||
|
const cy = h * 0.5;
|
||||||
|
const r = Math.min(w, h) * 0.32;
|
||||||
|
let angle = -Math.PI / 2;
|
||||||
|
data.forEach((row, i) => {
|
||||||
|
const slice = (Number(row.value) / total) * Math.PI * 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy);
|
||||||
|
ctx.fillStyle = COLORS[i % COLORS.length];
|
||||||
|
ctx.arc(cx, cy, r, angle, angle + slice);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
angle += slice;
|
||||||
|
});
|
||||||
|
let ly = 16;
|
||||||
|
data.forEach((row, i) => {
|
||||||
|
ctx.fillStyle = COLORS[i % COLORS.length];
|
||||||
|
ctx.fillRect(w * 0.62, ly, 10, 10);
|
||||||
|
ctx.fillStyle = '#cbd5e1';
|
||||||
|
ctx.font = '11px system-ui, sans-serif';
|
||||||
|
const pct = total > 0 ? Math.round((100 * Number(row.value)) / total) : 0;
|
||||||
|
ctx.fillText(String(row.label || '') + ' (' + pct + '%)', w * 0.62 + 16, ly + 9);
|
||||||
|
ly += 18;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBreakdownList(target, rows) {
|
||||||
|
if (!target) return;
|
||||||
|
if (!Array.isArray(rows) || !rows.length) {
|
||||||
|
target.textContent = 'No data';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const total = rows.reduce((s, r) => s + Number(r.value || 0), 0) || 1;
|
||||||
|
target.innerHTML = rows
|
||||||
|
.map((r) => {
|
||||||
|
const pct = Math.round((100 * Number(r.value || 0)) / total);
|
||||||
|
return (
|
||||||
|
'<div class="graph-breakdown-row"><span>' +
|
||||||
|
escapeHtml(String(r.label || '')) +
|
||||||
|
'</span><strong>' +
|
||||||
|
Number(r.value || 0) +
|
||||||
|
' <span class="muted">(' +
|
||||||
|
pct +
|
||||||
|
'%)</span></strong></div>'
|
||||||
|
);
|
||||||
|
})
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTopList(target, rows) {
|
||||||
|
if (!target) return;
|
||||||
|
if (!Array.isArray(rows) || !rows.length) {
|
||||||
|
target.textContent = 'No data';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = rows
|
||||||
|
.map((r) => {
|
||||||
|
const label = escapeHtml(String(r.label || ''));
|
||||||
|
const val = Number(r.value || 0);
|
||||||
|
if (r.href) {
|
||||||
|
return (
|
||||||
|
'<div class="graph-breakdown-row"><a href="' +
|
||||||
|
escapeHtml(r.href) +
|
||||||
|
'">' +
|
||||||
|
label +
|
||||||
|
'</a><strong>' +
|
||||||
|
val +
|
||||||
|
'</strong></div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return '<div class="graph-breakdown-row"><span>' + label + '</span><strong>' + val + '</strong></div>';
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.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() {
|
function bootGraphs() {
|
||||||
const app = document.getElementById('graphs-app');
|
const app = el('graphs-app');
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
const status = document.getElementById('graphs-status');
|
const status = el('graphs-status');
|
||||||
const daysSel = document.getElementById('graphs-days');
|
const daysSel = el('graphs-days');
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
const days = Number(daysSel && daysSel.value ? daysSel.value : 14);
|
const days = Number(daysSel && daysSel.value ? daysSel.value : 14);
|
||||||
status.textContent = 'Loading...';
|
status.textContent = 'Loading…';
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('GET', basePath() + '/api/graphs.php?days=' + encodeURIComponent(String(days)), true);
|
xhr.open('GET', basePath() + '/api/graphs.php?days=' + encodeURIComponent(String(days)), true);
|
||||||
xhr.onload = function () {
|
xhr.onload = function () {
|
||||||
@@ -81,13 +255,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = payload.data || {};
|
const data = payload.data || {};
|
||||||
drawLineChart(document.getElementById('graph-user-sessions'), data.user && data.user.sessions_per_day, '#3d8bfd');
|
const viewer = data.viewer || 'user';
|
||||||
drawLineChart(document.getElementById('graph-slug-sessions'), data.slug_admin && data.slug_admin.sessions_per_day, '#5eead4');
|
renderLinks(data.links || {});
|
||||||
drawLineChart(document.getElementById('graph-platform-sessions'), data.platform_admin && data.platform_admin.sessions_per_day, '#f59e0b');
|
setScopeVisibility(viewer);
|
||||||
drawLineChart(document.getElementById('graph-bitrate'), data.platform_admin && data.platform_admin.avg_bitrate_kbps_per_day, '#34d399');
|
renderScope('user', data.user);
|
||||||
renderBreakdown(document.getElementById('graph-ntp-breakdown'), data.platform_admin && data.platform_admin.ntp_sources);
|
if (data.slug_admin) renderScope('slug', data.slug_admin);
|
||||||
renderBreakdown(document.getElementById('graph-install-breakdown'), data.platform_admin && data.platform_admin.install_sources);
|
if (data.platform_admin) renderScope('platform', data.platform_admin);
|
||||||
status.textContent = 'Loaded';
|
status.textContent = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd';
|
||||||
};
|
};
|
||||||
xhr.onerror = function () {
|
xhr.onerror = function () {
|
||||||
status.textContent = 'Network error';
|
status.textContent = 'Network error';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
$opts = getopt('', ['count::', 'days::', 'out::', 'company::', 'user::']);
|
$opts = getopt('', ['count::', 'days::', 'out::', 'company::', 'user::']);
|
||||||
$count = max(10, (int) ($opts['count'] ?? 180));
|
$count = max(10, (int) ($opts['count'] ?? 180));
|
||||||
$days = max(1, (int) ($opts['days'] ?? 14));
|
$days = max(1, (int) ($opts['days'] ?? 14));
|
||||||
$out = $opts['out'] ?? (__DIR__ . '/../../../../tmp/fake_graph_sessions.ndjson');
|
$out = $opts['out'] ?? '/tmp/fake_graph_sessions.ndjson';
|
||||||
$companyId = (int) ($opts['company'] ?? 1);
|
$companyId = (int) ($opts['company'] ?? 1);
|
||||||
$userId = (int) ($opts['user'] ?? 1);
|
$userId = (int) ($opts['user'] ?? 1);
|
||||||
|
|
||||||
@@ -31,14 +31,20 @@ for ($i = 0; $i < $count; $i++) {
|
|||||||
'session_id' => 'fake-' . gmdate('Ymd', (int) ($start / 1000)) . '-' . substr(sha1((string) ($start . ':' . $i)), 0, 12),
|
'session_id' => 'fake-' . gmdate('Ymd', (int) ($start / 1000)) . '-' . substr(sha1((string) ($start . ':' . $i)), 0, 12),
|
||||||
'company_id' => $companyId,
|
'company_id' => $companyId,
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
|
'device_id' => 'fake-dev-' . ($i % 40),
|
||||||
|
'app_version' => '0.1.' . ($i % 5),
|
||||||
|
'sdk_int' => 29 + ($i % 6),
|
||||||
'started_at_epoch_ms' => $start,
|
'started_at_epoch_ms' => $start,
|
||||||
'ended_at_epoch_ms' => $end,
|
'ended_at_epoch_ms' => $end,
|
||||||
'duration_s' => $duration,
|
'duration_s' => $duration,
|
||||||
|
'completed' => ($i % 17 !== 0),
|
||||||
'direction' => ($i % 3 === 0) ? 'receiver' : 'sender',
|
'direction' => ($i % 3 === 0) ? 'receiver' : 'sender',
|
||||||
'transport' => $transports[array_rand($transports)],
|
'transport' => $transports[array_rand($transports)],
|
||||||
'avg_kbps' => random_int(640, 12000),
|
'avg_kbps' => random_int(640, 12000),
|
||||||
|
'recv_kbps' => random_int(200, 4000),
|
||||||
'packet_loss_pct' => round(mt_rand(0, 600) / 100, 2),
|
'packet_loss_pct' => round(mt_rand(0, 600) / 100, 2),
|
||||||
'ntp_source' => $ntp[array_rand($ntp)],
|
'ntp_source' => $ntp[array_rand($ntp)],
|
||||||
|
'ntp_correction_s' => random_int(0, 8),
|
||||||
'install_source' => $installs[array_rand($installs)],
|
'install_source' => $installs[array_rand($installs)],
|
||||||
'meta' => ['fake' => true, 'seed' => $i],
|
'meta' => ['fake' => true, 'seed' => $i],
|
||||||
];
|
];
|
||||||
|
|||||||
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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$opts = getopt('', ['file::', 'url::', 'dry-run']);
|
$opts = getopt('', ['file::', 'url::', 'dry-run']);
|
||||||
$file = $opts['file'] ?? (__DIR__ . '/../../../../tmp/fake_graph_sessions.ndjson');
|
$file = $opts['file'] ?? '/tmp/fake_graph_sessions.ndjson';
|
||||||
$base = rtrim($opts['url'] ?? 'https://apps.f0xx.org/app/androidcast_project/graphs', '/');
|
$base = (string) ($opts['url'] ?? 'https://apps.f0xx.org/app/androidcast_project/graphs');
|
||||||
|
if ($base === '' || $base === '1') {
|
||||||
|
$base = 'https://apps.f0xx.org/app/androidcast_project/graphs';
|
||||||
|
}
|
||||||
|
$base = rtrim($base, '/');
|
||||||
$dryRun = isset($opts['dry-run']);
|
$dryRun = isset($opts['dry-run']);
|
||||||
$api = $base . '/api/graph_upload.php';
|
$api = $base . '/api/graph_upload.php';
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
final class GraphRepository {
|
||||||
public static function ensureSchema(): void {
|
public static function ensureSchema(): void {
|
||||||
$pdo = Database::pdo();
|
$pdo = Database::pdo();
|
||||||
if (Database::tableExists($pdo, 'graph_sessions')) {
|
if (!Database::tableExists($pdo, 'graph_sessions')) {
|
||||||
|
self::createTable($pdo);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
self::ensureColumns($pdo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function createTable(PDO $pdo): void {
|
||||||
if (Database::isMysql()) {
|
if (Database::isMysql()) {
|
||||||
$pdo->exec(
|
$pdo->exec(
|
||||||
"CREATE TABLE IF NOT EXISTS graph_sessions (
|
"CREATE TABLE IF NOT EXISTS graph_sessions (
|
||||||
@@ -14,19 +19,27 @@ final class GraphRepository {
|
|||||||
session_id VARCHAR(96) NOT NULL,
|
session_id VARCHAR(96) NOT NULL,
|
||||||
company_id INT UNSIGNED NOT NULL DEFAULT 1,
|
company_id INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
user_id INT UNSIGNED NULL,
|
user_id INT UNSIGNED NULL,
|
||||||
|
device_id VARCHAR(128) NULL,
|
||||||
|
app_version VARCHAR(64) NULL,
|
||||||
|
sdk_int INT NULL,
|
||||||
started_at_ms BIGINT NOT NULL,
|
started_at_ms BIGINT NOT NULL,
|
||||||
ended_at_ms BIGINT NOT NULL,
|
ended_at_ms BIGINT NOT NULL,
|
||||||
duration_s INT NOT NULL,
|
duration_s INT NOT NULL,
|
||||||
|
completed TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender',
|
direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender',
|
||||||
transport VARCHAR(24) NOT NULL DEFAULT 'wifi',
|
transport VARCHAR(24) NOT NULL DEFAULT 'wifi',
|
||||||
avg_kbps INT NOT NULL DEFAULT 0,
|
avg_kbps INT NOT NULL DEFAULT 0,
|
||||||
|
recv_kbps INT NOT NULL DEFAULT 0,
|
||||||
packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0,
|
packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0,
|
||||||
ntp_source VARCHAR(32) NOT NULL DEFAULT 'device',
|
ntp_source VARCHAR(32) NOT NULL DEFAULT 'device',
|
||||||
|
ntp_correction_s INT NULL,
|
||||||
install_source VARCHAR(32) NOT NULL DEFAULT 'direct',
|
install_source VARCHAR(32) NOT NULL DEFAULT 'direct',
|
||||||
meta_json LONGTEXT NULL,
|
meta_json LONGTEXT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE KEY uq_graph_session_id (session_id),
|
UNIQUE KEY uq_graph_session_id (session_id),
|
||||||
KEY idx_graph_company_time (company_id, started_at_ms)
|
KEY idx_graph_company_time (company_id, started_at_ms),
|
||||||
|
KEY idx_graph_device (device_id),
|
||||||
|
KEY idx_graph_app_version (app_version)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -37,14 +50,20 @@ final class GraphRepository {
|
|||||||
session_id TEXT NOT NULL UNIQUE,
|
session_id TEXT NOT NULL UNIQUE,
|
||||||
company_id INTEGER NOT NULL DEFAULT 1,
|
company_id INTEGER NOT NULL DEFAULT 1,
|
||||||
user_id INTEGER NULL,
|
user_id INTEGER NULL,
|
||||||
|
device_id TEXT NULL,
|
||||||
|
app_version TEXT NULL,
|
||||||
|
sdk_int INTEGER NULL,
|
||||||
started_at_ms INTEGER NOT NULL,
|
started_at_ms INTEGER NOT NULL,
|
||||||
ended_at_ms INTEGER NOT NULL,
|
ended_at_ms INTEGER NOT NULL,
|
||||||
duration_s INTEGER NOT NULL,
|
duration_s INTEGER NOT NULL,
|
||||||
|
completed INTEGER NOT NULL DEFAULT 1,
|
||||||
direction TEXT NOT NULL DEFAULT 'sender',
|
direction TEXT NOT NULL DEFAULT 'sender',
|
||||||
transport TEXT NOT NULL DEFAULT 'wifi',
|
transport TEXT NOT NULL DEFAULT 'wifi',
|
||||||
avg_kbps INTEGER NOT NULL DEFAULT 0,
|
avg_kbps INTEGER NOT NULL DEFAULT 0,
|
||||||
|
recv_kbps INTEGER NOT NULL DEFAULT 0,
|
||||||
packet_loss_pct REAL NOT NULL DEFAULT 0.0,
|
packet_loss_pct REAL NOT NULL DEFAULT 0.0,
|
||||||
ntp_source TEXT NOT NULL DEFAULT 'device',
|
ntp_source TEXT NOT NULL DEFAULT 'device',
|
||||||
|
ntp_correction_s INTEGER NULL,
|
||||||
install_source TEXT NOT NULL DEFAULT 'direct',
|
install_source TEXT NOT NULL DEFAULT 'direct',
|
||||||
meta_json TEXT,
|
meta_json TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
@@ -52,6 +71,27 @@ final class GraphRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function ensureColumns(PDO $pdo): void {
|
||||||
|
$cols = [
|
||||||
|
'device_id' => Database::isMysql() ? 'VARCHAR(128) NULL' : 'TEXT NULL',
|
||||||
|
'app_version' => Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL',
|
||||||
|
'sdk_int' => Database::isMysql() ? 'INT NULL' : 'INTEGER NULL',
|
||||||
|
'completed' => Database::isMysql() ? 'TINYINT(1) NOT NULL DEFAULT 1' : 'INTEGER NOT NULL DEFAULT 1',
|
||||||
|
'recv_kbps' => Database::isMysql() ? 'INT NOT NULL DEFAULT 0' : 'INTEGER NOT NULL DEFAULT 0',
|
||||||
|
'ntp_correction_s' => Database::isMysql() ? 'INT NULL' : 'INTEGER NULL',
|
||||||
|
];
|
||||||
|
foreach ($cols as $name => $ddl) {
|
||||||
|
try {
|
||||||
|
$pdo->exec('ALTER TABLE graph_sessions ADD COLUMN ' . $name . ' ' . $ddl);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$msg = strtolower($e->getMessage());
|
||||||
|
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function insertSession(array $in): void {
|
public static function insertSession(array $in): void {
|
||||||
self::ensureSchema();
|
self::ensureSchema();
|
||||||
$pdo = Database::pdo();
|
$pdo = Database::pdo();
|
||||||
@@ -72,7 +112,7 @@ final class GraphRepository {
|
|||||||
if ($transport === '') {
|
if ($transport === '') {
|
||||||
$transport = 'wifi';
|
$transport = 'wifi';
|
||||||
}
|
}
|
||||||
$ntp = strtolower(trim((string) ($in['ntp_source'] ?? 'device')));
|
$ntp = strtolower(trim((string) ($in['ntp_source'] ?? ($in['time_source'] ?? 'device'))));
|
||||||
if ($ntp === '') {
|
if ($ntp === '') {
|
||||||
$ntp = 'device';
|
$ntp = 'device';
|
||||||
}
|
}
|
||||||
@@ -82,6 +122,7 @@ final class GraphRepository {
|
|||||||
}
|
}
|
||||||
$duration = max(0, (int) ($in['duration_s'] ?? (int) (($end - $start) / 1000)));
|
$duration = max(0, (int) ($in['duration_s'] ?? (int) (($end - $start) / 1000)));
|
||||||
$avgKbps = max(0, (int) ($in['avg_kbps'] ?? 0));
|
$avgKbps = max(0, (int) ($in['avg_kbps'] ?? 0));
|
||||||
|
$recvKbps = max(0, (int) ($in['recv_kbps'] ?? 0));
|
||||||
$loss = max(0.0, (float) ($in['packet_loss_pct'] ?? 0.0));
|
$loss = max(0.0, (float) ($in['packet_loss_pct'] ?? 0.0));
|
||||||
$companyId = (int) ($in['company_id'] ?? Rbac::defaultCompanyId());
|
$companyId = (int) ($in['company_id'] ?? Rbac::defaultCompanyId());
|
||||||
if ($companyId <= 0) {
|
if ($companyId <= 0) {
|
||||||
@@ -91,39 +132,75 @@ final class GraphRepository {
|
|||||||
if ($userId !== null && $userId <= 0) {
|
if ($userId !== null && $userId <= 0) {
|
||||||
$userId = null;
|
$userId = null;
|
||||||
}
|
}
|
||||||
|
$deviceId = trim((string) ($in['device_id'] ?? ''));
|
||||||
|
if ($deviceId === '' && is_array($in['device'] ?? null)) {
|
||||||
|
$deviceId = trim((string) (($in['device']['id'] ?? $in['device']['device_id'] ?? '')));
|
||||||
|
}
|
||||||
|
$appVersion = trim((string) ($in['app_version'] ?? ''));
|
||||||
|
if ($appVersion === '' && is_array($in['app'] ?? null)) {
|
||||||
|
$appVersion = trim((string) ($in['app']['version_name'] ?? $in['app']['version'] ?? ''));
|
||||||
|
}
|
||||||
|
$sdkInt = isset($in['sdk_int']) ? (int) $in['sdk_int'] : null;
|
||||||
|
if ($sdkInt === null && is_array($in['device'] ?? null)) {
|
||||||
|
$sdkInt = isset($in['device']['sdk_int']) ? (int) $in['device']['sdk_int'] : null;
|
||||||
|
}
|
||||||
|
$completed = 1;
|
||||||
|
if (array_key_exists('completed', $in)) {
|
||||||
|
$completed = !empty($in['completed']) ? 1 : 0;
|
||||||
|
}
|
||||||
|
$ntpCorrection = isset($in['ntp_correction_s']) ? (int) $in['ntp_correction_s'] : null;
|
||||||
|
if ($ntpCorrection === null && isset($in['ntp_correction_seconds'])) {
|
||||||
|
$ntpCorrection = (int) $in['ntp_correction_seconds'];
|
||||||
|
}
|
||||||
$meta = $in['meta'] ?? null;
|
$meta = $in['meta'] ?? null;
|
||||||
$metaJson = is_array($meta) ? safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null;
|
$metaJson = is_array($meta) ? safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null;
|
||||||
|
|
||||||
if (Database::isMysql()) {
|
if (Database::isMysql()) {
|
||||||
$sql = 'INSERT INTO graph_sessions
|
$sql = 'INSERT INTO graph_sessions
|
||||||
(session_id, company_id, user_id, started_at_ms, ended_at_ms, duration_s, direction, transport, avg_kbps, packet_loss_pct, ntp_source, install_source, meta_json)
|
(session_id, company_id, user_id, device_id, app_version, sdk_int,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
started_at_ms, ended_at_ms, duration_s, completed, direction, transport,
|
||||||
|
avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
ended_at_ms = VALUES(ended_at_ms),
|
ended_at_ms = VALUES(ended_at_ms),
|
||||||
duration_s = VALUES(duration_s),
|
duration_s = VALUES(duration_s),
|
||||||
|
completed = VALUES(completed),
|
||||||
avg_kbps = VALUES(avg_kbps),
|
avg_kbps = VALUES(avg_kbps),
|
||||||
|
recv_kbps = VALUES(recv_kbps),
|
||||||
packet_loss_pct = VALUES(packet_loss_pct),
|
packet_loss_pct = VALUES(packet_loss_pct),
|
||||||
ntp_source = VALUES(ntp_source),
|
ntp_source = VALUES(ntp_source),
|
||||||
|
ntp_correction_s = VALUES(ntp_correction_s),
|
||||||
install_source = VALUES(install_source),
|
install_source = VALUES(install_source),
|
||||||
|
device_id = COALESCE(VALUES(device_id), device_id),
|
||||||
|
app_version = COALESCE(VALUES(app_version), app_version),
|
||||||
|
sdk_int = COALESCE(VALUES(sdk_int), sdk_int),
|
||||||
meta_json = VALUES(meta_json)';
|
meta_json = VALUES(meta_json)';
|
||||||
} else {
|
} else {
|
||||||
$sql = 'INSERT OR REPLACE INTO graph_sessions
|
$sql = 'INSERT OR REPLACE INTO graph_sessions
|
||||||
(session_id, company_id, user_id, started_at_ms, ended_at_ms, duration_s, direction, transport, avg_kbps, packet_loss_pct, ntp_source, install_source, meta_json)
|
(session_id, company_id, user_id, device_id, app_version, sdk_int,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
started_at_ms, ended_at_ms, duration_s, completed, direction, transport,
|
||||||
|
avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
||||||
}
|
}
|
||||||
$stmt = $pdo->prepare($sql);
|
$stmt = $pdo->prepare($sql);
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
$sessionId,
|
$sessionId,
|
||||||
$companyId,
|
$companyId,
|
||||||
$userId,
|
$userId,
|
||||||
|
$deviceId !== '' ? $deviceId : null,
|
||||||
|
$appVersion !== '' ? $appVersion : null,
|
||||||
|
$sdkInt,
|
||||||
$start,
|
$start,
|
||||||
$end,
|
$end,
|
||||||
$duration,
|
$duration,
|
||||||
|
$completed,
|
||||||
$dir,
|
$dir,
|
||||||
$transport,
|
$transport,
|
||||||
$avgKbps,
|
$avgKbps,
|
||||||
|
$recvKbps,
|
||||||
$loss,
|
$loss,
|
||||||
$ntp,
|
$ntp,
|
||||||
|
$ntpCorrection,
|
||||||
$install,
|
$install,
|
||||||
$metaJson,
|
$metaJson,
|
||||||
]);
|
]);
|
||||||
@@ -131,79 +208,139 @@ final class GraphRepository {
|
|||||||
|
|
||||||
public static function dashboardData(int $days = 14): array {
|
public static function dashboardData(int $days = 14): array {
|
||||||
self::ensureSchema();
|
self::ensureSchema();
|
||||||
$days = max(3, min(60, $days));
|
$days = max(1, min(60, $days));
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
$uid = (int) ($user['id'] ?? 0);
|
$uid = (int) ($user['id'] ?? 0);
|
||||||
$scopeUserId = $uid > 0 ? $uid : null;
|
$scopeUserId = $uid > 0 ? $uid : null;
|
||||||
$activeCompanyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
|
$activeCompanyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
|
||||||
|
$viewer = self::viewerRole($user);
|
||||||
|
|
||||||
return [
|
$payload = [
|
||||||
'window_days' => $days,
|
'window_days' => $days,
|
||||||
'user' => self::buildScope($days, $activeCompanyId, $scopeUserId),
|
'viewer' => $viewer,
|
||||||
'slug_admin' => self::buildScope($days, $activeCompanyId, null),
|
'links' => self::serviceLinks(),
|
||||||
'platform_admin' => self::buildScope($days, null, null),
|
];
|
||||||
|
$payload['user'] = self::buildScope($days, $activeCompanyId, $scopeUserId);
|
||||||
|
if ($viewer === 'user') {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
$payload['slug_admin'] = self::buildScope($days, $activeCompanyId, null);
|
||||||
|
if ($viewer !== 'platform_admin') {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
$payload['platform_admin'] = self::buildScope($days, null, null, true);
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function viewerRole(?array $user): string {
|
||||||
|
if (Rbac::isGlobalAdmin($user)) {
|
||||||
|
return 'platform_admin';
|
||||||
|
}
|
||||||
|
$companyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
|
||||||
|
$uid = (int) ($user['id'] ?? 0);
|
||||||
|
$role = ($companyId > 0 && $uid > 0) ? Rbac::companyRole($uid, $companyId) : null;
|
||||||
|
if ($role === 'owner' || $role === 'admin') {
|
||||||
|
return 'slug_admin';
|
||||||
|
}
|
||||||
|
return 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
private static function serviceLinks(): array {
|
||||||
|
$prefix = '/app/androidcast_project';
|
||||||
|
return [
|
||||||
|
'hub' => $prefix . '/',
|
||||||
|
'graphs' => $prefix . '/graphs/',
|
||||||
|
'crashes' => $prefix . '/crashes/?view=reports',
|
||||||
|
'tickets' => $prefix . '/crashes/?view=tickets',
|
||||||
|
'build' => $prefix . '/build/',
|
||||||
|
'git' => $prefix . '/git/',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function buildScope(int $days, ?int $companyId, ?int $userId): array {
|
private static function buildScope(int $days, ?int $companyId, ?int $userId, bool $platform = false): array {
|
||||||
$startMs = (int) ((time() - ($days * 86400)) * 1000);
|
$startMs = (int) ((time() - ($days * 86400)) * 1000);
|
||||||
return [
|
$scope = [
|
||||||
'sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId),
|
'sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId),
|
||||||
|
'unique_devices_per_day' => self::uniqueDevicesPerDay($startMs, $companyId, $userId),
|
||||||
|
'avg_duration_s_per_day' => self::avgDurationPerDay($startMs, $companyId, $userId),
|
||||||
|
'send_sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId, 'sender'),
|
||||||
|
'recv_sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId, 'receiver'),
|
||||||
|
'success_ratio_pct' => self::successRatio($startMs, $companyId, $userId),
|
||||||
|
'avg_bitrate_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId, 'avg_kbps'),
|
||||||
|
'avg_recv_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId, 'recv_kbps'),
|
||||||
'crashes_per_day' => self::crashesPerDay($startMs, $companyId),
|
'crashes_per_day' => self::crashesPerDay($startMs, $companyId),
|
||||||
'avg_bitrate_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId),
|
|
||||||
'ntp_sources' => self::breakdown('ntp_source', $startMs, $companyId, $userId),
|
'ntp_sources' => self::breakdown('ntp_source', $startMs, $companyId, $userId),
|
||||||
'install_sources' => self::breakdown('install_source', $startMs, $companyId, $userId),
|
'install_sources' => self::breakdown('install_source', $startMs, $companyId, $userId),
|
||||||
'transport_mix' => self::breakdown('transport', $startMs, $companyId, $userId),
|
'transport_mix' => self::breakdown('transport', $startMs, $companyId, $userId),
|
||||||
|
'app_versions' => self::breakdown('app_version', $startMs, $companyId, $userId),
|
||||||
];
|
];
|
||||||
|
if (Database::tableExists(Database::pdo(), 'tickets')) {
|
||||||
|
$scope['tickets_per_day'] = self::ticketsPerDay($startMs, $companyId);
|
||||||
|
}
|
||||||
|
if ($platform) {
|
||||||
|
$scope['top_crash_fingerprints'] = self::topCrashFingerprints($startMs, 8);
|
||||||
|
$scope['crashes_by_app_version'] = self::crashesByAppVersion($startMs);
|
||||||
|
$scope['ntp_correction_avg_s'] = self::avgNtpCorrection($startMs, $companyId, $userId);
|
||||||
|
}
|
||||||
|
return $scope;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function sessionsPerDay(int $startMs, ?int $companyId, ?int $userId): array {
|
private static function sessionsPerDay(
|
||||||
|
int $startMs,
|
||||||
|
?int $companyId,
|
||||||
|
?int $userId,
|
||||||
|
?string $direction = null
|
||||||
|
): array {
|
||||||
|
$where = ['started_at_ms >= ?'];
|
||||||
|
$params = [$startMs];
|
||||||
|
if ($direction !== null) {
|
||||||
|
$where[] = 'direction = ?';
|
||||||
|
$params[] = $direction;
|
||||||
|
}
|
||||||
|
self::appendScope($where, $params, $companyId, $userId);
|
||||||
|
$sql = 'SELECT started_at_ms FROM graph_sessions WHERE ' . implode(' AND ', $where);
|
||||||
|
return self::countByDay($sql, $params, 'started_at_ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function uniqueDevicesPerDay(int $startMs, ?int $companyId, ?int $userId): array {
|
||||||
|
$where = ['started_at_ms >= ?', "device_id IS NOT NULL", "device_id <> ''"];
|
||||||
|
$params = [$startMs];
|
||||||
|
self::appendScope($where, $params, $companyId, $userId);
|
||||||
|
$sql = 'SELECT started_at_ms, device_id FROM graph_sessions WHERE ' . implode(' AND ', $where);
|
||||||
|
$stmt = Database::pdo()->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$bucket = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$day = gmdate('Y-m-d', intdiv((int) ($row['started_at_ms'] ?? 0), 1000));
|
||||||
|
$dev = (string) ($row['device_id'] ?? '');
|
||||||
|
if ($dev === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($bucket[$day])) {
|
||||||
|
$bucket[$day] = [];
|
||||||
|
}
|
||||||
|
$bucket[$day][$dev] = true;
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($bucket as $day => $set) {
|
||||||
|
$out[$day] = count($set);
|
||||||
|
}
|
||||||
|
return self::assocToSeries($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function avgDurationPerDay(int $startMs, ?int $companyId, ?int $userId): array {
|
||||||
$where = ['started_at_ms >= ?'];
|
$where = ['started_at_ms >= ?'];
|
||||||
$params = [$startMs];
|
$params = [$startMs];
|
||||||
self::appendScope($where, $params, $companyId, $userId);
|
self::appendScope($where, $params, $companyId, $userId);
|
||||||
$sql = 'SELECT started_at_ms FROM graph_sessions WHERE ' . implode(' AND ', $where) . ' ORDER BY started_at_ms ASC';
|
$sql = 'SELECT started_at_ms, duration_s FROM graph_sessions WHERE ' . implode(' AND ', $where);
|
||||||
$stmt = Database::pdo()->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
$bucket = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$day = gmdate('Y-m-d', ((int) ($row['started_at_ms'] ?? 0)) / 1000);
|
|
||||||
$bucket[$day] = ($bucket[$day] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
return self::assocToSeries($bucket);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function crashesPerDay(int $startMs, ?int $companyId): array {
|
|
||||||
$where = ['received_at_ms >= ?'];
|
|
||||||
$params = [$startMs];
|
|
||||||
if ($companyId !== null && $companyId > 0) {
|
|
||||||
$where[] = 'company_id = ?';
|
|
||||||
$params[] = $companyId;
|
|
||||||
}
|
|
||||||
$sql = 'SELECT received_at_ms FROM reports WHERE ' . implode(' AND ', $where) . ' ORDER BY received_at_ms ASC';
|
|
||||||
$stmt = Database::pdo()->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
$bucket = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$day = gmdate('Y-m-d', ((int) ($row['received_at_ms'] ?? 0)) / 1000);
|
|
||||||
$bucket[$day] = ($bucket[$day] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
return self::assocToSeries($bucket);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function avgBitratePerDay(int $startMs, ?int $companyId, ?int $userId): array {
|
|
||||||
$where = ['started_at_ms >= ?'];
|
|
||||||
$params = [$startMs];
|
|
||||||
self::appendScope($where, $params, $companyId, $userId);
|
|
||||||
$sql = 'SELECT started_at_ms, avg_kbps FROM graph_sessions WHERE ' . implode(' AND ', $where) . ' ORDER BY started_at_ms ASC';
|
|
||||||
$stmt = Database::pdo()->prepare($sql);
|
$stmt = Database::pdo()->prepare($sql);
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
$sum = [];
|
$sum = [];
|
||||||
$cnt = [];
|
$cnt = [];
|
||||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
$day = gmdate('Y-m-d', ((int) ($row['started_at_ms'] ?? 0)) / 1000);
|
$day = gmdate('Y-m-d', intdiv((int) ($row['started_at_ms'] ?? 0), 1000));
|
||||||
$sum[$day] = ($sum[$day] ?? 0.0) + (float) ($row['avg_kbps'] ?? 0);
|
$sum[$day] = ($sum[$day] ?? 0) + (int) ($row['duration_s'] ?? 0);
|
||||||
$cnt[$day] = ($cnt[$day] ?? 0) + 1;
|
$cnt[$day] = ($cnt[$day] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
$avg = [];
|
$avg = [];
|
||||||
@@ -213,25 +350,169 @@ final class GraphRepository {
|
|||||||
return self::assocToSeries($avg);
|
return self::assocToSeries($avg);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function breakdown(string $column, int $startMs, ?int $companyId, ?int $userId): array {
|
private static function successRatio(int $startMs, ?int $companyId, ?int $userId): float {
|
||||||
$allowed = ['ntp_source', 'install_source', 'transport'];
|
$where = ['started_at_ms >= ?'];
|
||||||
|
$params = [$startMs];
|
||||||
|
self::appendScope($where, $params, $companyId, $userId);
|
||||||
|
$sql = 'SELECT COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN completed = 1 THEN 1 ELSE 0 END) AS ok
|
||||||
|
FROM graph_sessions WHERE ' . implode(' AND ', $where);
|
||||||
|
$stmt = Database::pdo()->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
$total = (int) ($row['total'] ?? 0);
|
||||||
|
$ok = (int) ($row['ok'] ?? 0);
|
||||||
|
if ($total <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
return round(100.0 * $ok / $total, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function avgBitratePerDay(
|
||||||
|
int $startMs,
|
||||||
|
?int $companyId,
|
||||||
|
?int $userId,
|
||||||
|
string $column
|
||||||
|
): array {
|
||||||
|
$allowed = ['avg_kbps', 'recv_kbps'];
|
||||||
if (!in_array($column, $allowed, true)) {
|
if (!in_array($column, $allowed, true)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$where = ['started_at_ms >= ?'];
|
$where = ['started_at_ms >= ?'];
|
||||||
$params = [$startMs];
|
$params = [$startMs];
|
||||||
self::appendScope($where, $params, $companyId, $userId);
|
self::appendScope($where, $params, $companyId, $userId);
|
||||||
|
$sql = 'SELECT started_at_ms, ' . $column . ' AS v FROM graph_sessions WHERE '
|
||||||
|
. implode(' AND ', $where);
|
||||||
|
$stmt = Database::pdo()->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$sum = [];
|
||||||
|
$cnt = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$day = gmdate('Y-m-d', intdiv((int) ($row['started_at_ms'] ?? 0), 1000));
|
||||||
|
$sum[$day] = ($sum[$day] ?? 0.0) + (float) ($row['v'] ?? 0);
|
||||||
|
$cnt[$day] = ($cnt[$day] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
$avg = [];
|
||||||
|
foreach ($sum as $day => $total) {
|
||||||
|
$avg[$day] = $cnt[$day] > 0 ? round($total / $cnt[$day], 1) : 0.0;
|
||||||
|
}
|
||||||
|
return self::assocToSeries($avg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function crashesPerDay(int $startMs, ?int $companyId): array {
|
||||||
|
if (!Database::tableExists(Database::pdo(), 'reports')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$where = ['received_at_ms >= ?'];
|
||||||
|
$params = [$startMs];
|
||||||
|
if ($companyId !== null && $companyId > 0) {
|
||||||
|
$where[] = 'company_id = ?';
|
||||||
|
$params[] = $companyId;
|
||||||
|
}
|
||||||
|
$sql = 'SELECT received_at_ms FROM reports WHERE ' . implode(' AND ', $where);
|
||||||
|
return self::countByDay($sql, $params, 'received_at_ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ticketsPerDay(int $startMs, ?int $companyId): array {
|
||||||
|
$where = ['opened_at_ms >= ?'];
|
||||||
|
$params = [$startMs];
|
||||||
|
if ($companyId !== null && $companyId > 0) {
|
||||||
|
$where[] = 'company_id = ?';
|
||||||
|
$params[] = $companyId;
|
||||||
|
}
|
||||||
|
$sql = 'SELECT opened_at_ms FROM tickets WHERE ' . implode(' AND ', $where);
|
||||||
|
return self::countByDay($sql, $params, 'opened_at_ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function topCrashFingerprints(int $startMs, int $limit): array {
|
||||||
|
if (!Database::tableExists(Database::pdo(), 'reports')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$sql = 'SELECT fingerprint, COUNT(*) AS c, MAX(id) AS last_id
|
||||||
|
FROM reports WHERE received_at_ms >= ?
|
||||||
|
GROUP BY fingerprint ORDER BY c DESC LIMIT ' . (int) $limit;
|
||||||
|
$stmt = Database::pdo()->prepare($sql);
|
||||||
|
$stmt->execute([$startMs]);
|
||||||
|
$base = Auth::basePath();
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$fp = (string) ($row['fingerprint'] ?? '');
|
||||||
|
$id = (int) ($row['last_id'] ?? 0);
|
||||||
|
$out[] = [
|
||||||
|
'label' => $fp,
|
||||||
|
'value' => (int) ($row['c'] ?? 0),
|
||||||
|
'href' => $id > 0 ? $base . '/?view=report&id=' . $id : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function crashesByAppVersion(int $startMs): array {
|
||||||
|
if (!Database::tableExists(Database::pdo(), 'reports')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$sql = "SELECT COALESCE(NULLIF(app_version, ''), 'unknown') AS k, COUNT(*) AS c
|
||||||
|
FROM reports WHERE received_at_ms >= ?
|
||||||
|
GROUP BY k ORDER BY c DESC LIMIT 12";
|
||||||
|
$stmt = Database::pdo()->prepare($sql);
|
||||||
|
$stmt->execute([$startMs]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function avgNtpCorrection(int $startMs, ?int $companyId, ?int $userId): ?float {
|
||||||
|
$where = ['started_at_ms >= ?', 'ntp_correction_s IS NOT NULL'];
|
||||||
|
$params = [$startMs];
|
||||||
|
self::appendScope($where, $params, $companyId, $userId);
|
||||||
|
$sql = 'SELECT AVG(ABS(ntp_correction_s)) AS avg_s FROM graph_sessions WHERE '
|
||||||
|
. implode(' AND ', $where);
|
||||||
|
$stmt = Database::pdo()->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$v = $stmt->fetchColumn();
|
||||||
|
return $v === false || $v === null ? null : round((float) $v, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function breakdown(string $column, int $startMs, ?int $companyId, ?int $userId): array {
|
||||||
|
$allowed = ['ntp_source', 'install_source', 'transport', 'app_version'];
|
||||||
|
if (!in_array($column, $allowed, true)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$where = ['started_at_ms >= ?'];
|
||||||
|
$params = [$startMs];
|
||||||
|
if ($column === 'app_version') {
|
||||||
|
$where[] = "app_version IS NOT NULL";
|
||||||
|
$where[] = "app_version <> ''";
|
||||||
|
}
|
||||||
|
self::appendScope($where, $params, $companyId, $userId);
|
||||||
$sql = 'SELECT ' . $column . ' AS k, COUNT(*) AS c FROM graph_sessions WHERE '
|
$sql = 'SELECT ' . $column . ' AS k, COUNT(*) AS c FROM graph_sessions WHERE '
|
||||||
. implode(' AND ', $where) . ' GROUP BY ' . $column . ' ORDER BY c DESC';
|
. implode(' AND ', $where) . ' GROUP BY ' . $column . ' ORDER BY c DESC';
|
||||||
$stmt = Database::pdo()->prepare($sql);
|
$stmt = Database::pdo()->prepare($sql);
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
$out = [];
|
$out = [];
|
||||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
|
$label = (string) ($row['k'] ?? 'unknown');
|
||||||
|
if ($label === '') {
|
||||||
|
$label = 'unknown';
|
||||||
|
}
|
||||||
|
$out[] = ['label' => $label, 'value' => (int) ($row['c'] ?? 0)];
|
||||||
}
|
}
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function countByDay(string $sql, array $params, string $msColumn): array {
|
||||||
|
$stmt = Database::pdo()->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$bucket = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$day = gmdate('Y-m-d', intdiv((int) ($row[$msColumn] ?? 0), 1000));
|
||||||
|
$bucket[$day] = ($bucket[$day] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return self::assocToSeries($bucket);
|
||||||
|
}
|
||||||
|
|
||||||
private static function appendScope(array &$where, array &$params, ?int $companyId, ?int $userId): void {
|
private static function appendScope(array &$where, array &$params, ?int $companyId, ?int $userId): void {
|
||||||
if ($companyId !== null && $companyId > 0) {
|
if ($companyId !== null && $companyId > 0) {
|
||||||
$where[] = 'company_id = ?';
|
$where[] = 'company_id = ?';
|
||||||
|
|||||||
@@ -235,7 +235,7 @@
|
|||||||
<nav class="reports-pagination" id="reports-pagination" aria-label="Reports pages"></nav>
|
<nav class="reports-pagination" id="reports-pagination" aria-label="Reports pages"></nav>
|
||||||
</div>
|
</div>
|
||||||
<?php elseif (($view ?? '') === 'graphs'): ?>
|
<?php elseif (($view ?? '') === 'graphs'): ?>
|
||||||
<div id="graphs-app" class="reports-app">
|
<div id="graphs-app" class="reports-app graphs-app">
|
||||||
<div class="toolbar reports-toolbar">
|
<div class="toolbar reports-toolbar">
|
||||||
<h1>AndroidCast graphs</h1>
|
<h1>AndroidCast graphs</h1>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
@@ -249,6 +249,7 @@
|
|||||||
<label class="toolbar-select">
|
<label class="toolbar-select">
|
||||||
<span>Window</span>
|
<span>Window</span>
|
||||||
<select id="graphs-days" aria-label="Graphs window">
|
<select id="graphs-days" aria-label="Graphs window">
|
||||||
|
<option value="1">1d</option>
|
||||||
<option value="7">7d</option>
|
<option value="7">7d</option>
|
||||||
<option value="14" selected>14d</option>
|
<option value="14" selected>14d</option>
|
||||||
<option value="30">30d</option>
|
<option value="30">30d</option>
|
||||||
@@ -256,36 +257,56 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<nav id="graphs-quick-links" class="graphs-quick-links" aria-label="Related services"></nav>
|
||||||
<p id="graphs-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
<p id="graphs-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||||
<div class="cards cards--lift">
|
|
||||||
<article class="card card--lift">
|
<section class="graphs-scope" data-graph-scope="user">
|
||||||
<h3>User scope</h3>
|
<h2 class="graphs-scope-title">Your activity</h2>
|
||||||
<canvas id="graph-user-sessions" width="560" height="180"></canvas>
|
<div class="cards cards--lift graphs-grid">
|
||||||
</article>
|
<article class="card card--lift"><h3>Sessions / day</h3><canvas id="graph-user-sessions" width="520" height="160"></canvas></article>
|
||||||
<article class="card card--lift">
|
<article class="card card--lift"><h3>Unique devices / day</h3><canvas id="graph-user-devices" width="520" height="160"></canvas></article>
|
||||||
<h3>Slug-admin scope</h3>
|
<article class="card card--lift"><h3>Avg duration (s) / day</h3><canvas id="graph-user-duration" width="520" height="160"></canvas></article>
|
||||||
<canvas id="graph-slug-sessions" width="560" height="180"></canvas>
|
<article class="card card--lift"><h3>Send sessions / day</h3><canvas id="graph-user-send" width="520" height="160"></canvas></article>
|
||||||
</article>
|
<article class="card card--lift"><h3>Receive sessions / day</h3><canvas id="graph-user-recv" width="520" height="160"></canvas></article>
|
||||||
<article class="card card--lift">
|
<article class="card card--lift"><h3>Success ratio</h3><p id="graph-user-success" class="graph-kpi">—</p></article>
|
||||||
<h3>Platform scope</h3>
|
<article class="card card--lift"><h3>Outbound kbps / day</h3><canvas id="graph-user-bitrate" width="520" height="160"></canvas></article>
|
||||||
<canvas id="graph-platform-sessions" width="560" height="180"></canvas>
|
<article class="card card--lift"><h3>Inbound kbps / day</h3><canvas id="graph-user-recv-kbps" width="520" height="160"></canvas></article>
|
||||||
</article>
|
<article class="card card--lift"><h3>Crashes / day</h3><canvas id="graph-user-crashes" width="520" height="160"></canvas></article>
|
||||||
<article class="card card--lift">
|
|
||||||
<h3>Bitrate (kbps)</h3>
|
|
||||||
<canvas id="graph-bitrate" width="560" height="180"></canvas>
|
|
||||||
</article>
|
|
||||||
<article class="card card--lift">
|
|
||||||
<h3>NTP source mix</h3>
|
|
||||||
<div id="graph-ntp-breakdown" class="muted">—</div>
|
|
||||||
</article>
|
|
||||||
<article class="card card--lift">
|
|
||||||
<h3>Install source mix</h3>
|
|
||||||
<div id="graph-install-breakdown" class="muted">—</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">
|
</section>
|
||||||
APIs: <code><?= h(Auth::basePath()) ?>/api/graphs.php</code> and
|
|
||||||
|
<section class="graphs-scope" data-graph-scope="slug_admin" hidden>
|
||||||
|
<h2 class="graphs-scope-title">Company (slug admin)</h2>
|
||||||
|
<div class="cards cards--lift graphs-grid">
|
||||||
|
<article class="card card--lift"><h3>Sessions / day</h3><canvas id="graph-slug-sessions" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Unique devices / day</h3><canvas id="graph-slug-devices" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Install sources</h3><canvas id="graph-slug-install-pie" width="520" height="180"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>NTP sources</h3><canvas id="graph-slug-ntp-pie" width="520" height="180"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Transport mix</h3><div id="graph-slug-transport" class="graph-breakdown"></div></article>
|
||||||
|
<article class="card card--lift"><h3>App versions</h3><div id="graph-slug-versions" class="graph-breakdown"></div></article>
|
||||||
|
<article class="card card--lift"><h3>Crashes / day</h3><canvas id="graph-slug-crashes" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Tickets / day</h3><canvas id="graph-slug-tickets" width="520" height="160"></canvas></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="graphs-scope" data-graph-scope="platform_admin" hidden>
|
||||||
|
<h2 class="graphs-scope-title">Platform reliability</h2>
|
||||||
|
<div class="cards cards--lift graphs-grid">
|
||||||
|
<article class="card card--lift"><h3>Sessions / day (all slugs)</h3><canvas id="graph-platform-sessions" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Crashes / day</h3><canvas id="graph-platform-crashes" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Tickets / day</h3><canvas id="graph-platform-tickets" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Install sources</h3><canvas id="graph-platform-install-pie" width="520" height="180"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>NTP correction</h3><p id="graph-platform-ntp-avg" class="graph-kpi muted">—</p></article>
|
||||||
|
<article class="card card--lift"><h3>Top crash fingerprints</h3><div id="graph-platform-fingerprints" class="graph-breakdown"></div></article>
|
||||||
|
<article class="card card--lift"><h3>Crashes by app version</h3><canvas id="graph-platform-crash-versions" width="520" height="180"></canvas></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="muted graphs-footnote">
|
||||||
|
Direct DB metrics (no ETL). Ingest:
|
||||||
<code><?= h(Auth::basePath()) ?>/api/graph_upload.php</code>
|
<code><?= h(Auth::basePath()) ?>/api/graph_upload.php</code>
|
||||||
|
· Read:
|
||||||
|
<code><?= h(Auth::basePath()) ?>/api/graphs.php</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -110,19 +110,25 @@ return [
|
|||||||
];
|
];
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
SRC_MIG_070="$ROOT_DIR/runtime/db-init/070_graph_ecosystem.sql"
|
||||||
|
|
||||||
|
write_migration_with_db() {
|
||||||
|
local src="$1"
|
||||||
|
local dest="$2"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "USE \`${MARIADB_DATABASE}\`;"
|
||||||
|
cat "$src"
|
||||||
|
} >"$tmp"
|
||||||
|
mv "$tmp" "$dest"
|
||||||
|
}
|
||||||
|
|
||||||
cp "$SRC_SCHEMA" "$DB_INIT_DIR/010_schema.sql"
|
cp "$SRC_SCHEMA" "$DB_INIT_DIR/010_schema.sql"
|
||||||
{
|
write_migration_with_db "$SRC_MIG_004" "$DB_INIT_DIR/040_ticket_workflow.sql"
|
||||||
echo "USE \`${MARIADB_DATABASE}\`;"
|
write_migration_with_db "$SRC_MIG_005" "$DB_INIT_DIR/050_ticket_attachments.sql"
|
||||||
cat "$SRC_MIG_004"
|
write_migration_with_db "$SRC_MIG_060" "$DB_INIT_DIR/060_build_ecosystem.sql"
|
||||||
} >"$DB_INIT_DIR/040_ticket_workflow.sql"
|
write_migration_with_db "$SRC_MIG_070" "$DB_INIT_DIR/070_graph_ecosystem.sql"
|
||||||
{
|
|
||||||
echo "USE \`${MARIADB_DATABASE}\`;"
|
|
||||||
cat "$SRC_MIG_005"
|
|
||||||
} >"$DB_INIT_DIR/050_ticket_attachments.sql"
|
|
||||||
{
|
|
||||||
echo "USE \`${MARIADB_DATABASE}\`;"
|
|
||||||
cat "$SRC_MIG_060"
|
|
||||||
} >"$DB_INIT_DIR/060_build_ecosystem.sql"
|
|
||||||
|
|
||||||
echo "Pulling images..."
|
echo "Pulling images..."
|
||||||
docker compose -f "$ROOT_DIR/docker-compose.yml" pull landing-fe gitea-fe crashes-fe build-fe mariadb gitea || true
|
docker compose -f "$ROOT_DIR/docker-compose.yml" pull landing-fe gitea-fe crashes-fe build-fe mariadb gitea || true
|
||||||
@@ -135,6 +141,7 @@ echo "Stack is up."
|
|||||||
echo "Landing: ${PUBLIC_BASE_URL}/app/androidcast_project/"
|
echo "Landing: ${PUBLIC_BASE_URL}/app/androidcast_project/"
|
||||||
echo "Gitea (direct FE): http://localhost:${GITEA_FE_PORT}/app/androidcast_project/git/"
|
echo "Gitea (direct FE): http://localhost:${GITEA_FE_PORT}/app/androidcast_project/git/"
|
||||||
echo "Crashes (direct FE): http://localhost:${CRASHES_FE_PORT}/app/androidcast_project/crashes/"
|
echo "Crashes (direct FE): http://localhost:${CRASHES_FE_PORT}/app/androidcast_project/crashes/"
|
||||||
|
echo "Graphs (via landing): ${PUBLIC_BASE_URL}/app/androidcast_project/graphs/"
|
||||||
echo "Builder (direct FE): http://localhost:${BUILD_FE_PORT:-8083}/app/androidcast_project/build/"
|
echo "Builder (direct FE): http://localhost:${BUILD_FE_PORT:-8083}/app/androidcast_project/build/"
|
||||||
echo "Shared login: same user/password as Crashes (session cookie path /app/androidcast_project)"
|
echo "Shared login: same user/password as Crashes (session cookie path /app/androidcast_project)"
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -9,6 +9,43 @@ server {
|
|||||||
return 302 /app/androidcast_project/crashes/;
|
return 302 /app/androidcast_project/crashes/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /app/androidcast_project/graphs {
|
||||||
|
return 302 /app/androidcast_project/graphs/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /app/androidcast_project/graphs/assets/ {
|
||||||
|
alias /workspace/examples/crash_reporter/backend/public/assets/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /app/androidcast_project/graphs/api/graphs.php {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /workspace/examples/crash_reporter/backend/public/api/graphs.php;
|
||||||
|
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php;
|
||||||
|
fastcgi_param DOCUMENT_ROOT /workspace/examples/crash_reporter/backend/public;
|
||||||
|
fastcgi_pass crash-php:9000;
|
||||||
|
fastcgi_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /app/androidcast_project/graphs/api/graph_upload.php {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /workspace/examples/crash_reporter/backend/public/api/graph_upload.php;
|
||||||
|
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php;
|
||||||
|
fastcgi_param DOCUMENT_ROOT /workspace/examples/crash_reporter/backend/public;
|
||||||
|
fastcgi_pass crash-php:9000;
|
||||||
|
client_max_body_size 4m;
|
||||||
|
fastcgi_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /app/androidcast_project/graphs/ {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /workspace/examples/crash_reporter/backend/public/index.php;
|
||||||
|
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php;
|
||||||
|
fastcgi_param REQUEST_URI $request_uri;
|
||||||
|
fastcgi_param DOCUMENT_ROOT /workspace/examples/crash_reporter/backend/public;
|
||||||
|
fastcgi_pass crash-php:9000;
|
||||||
|
fastcgi_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
location /app/androidcast_project/crashes/ {
|
location /app/androidcast_project/crashes/ {
|
||||||
alias /workspace/examples/crash_reporter/backend/public/;
|
alias /workspace/examples/crash_reporter/backend/public/;
|
||||||
index index.php;
|
index index.php;
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /app/androidcast_project/graphs/ {
|
||||||
|
proxy_pass http://crashes-fe:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location /app/androidcast_project/build/ {
|
location /app/androidcast_project/build/ {
|
||||||
proxy_pass http://build-fe:8080;
|
proxy_pass http://build-fe:8080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
USE `androidcast_crashes`;
|
||||||
CREATE TABLE IF NOT EXISTS builds (
|
CREATE TABLE IF NOT EXISTS builds (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
build_code VARCHAR(64) NOT NULL,
|
build_code VARCHAR(64) NOT NULL,
|
||||||
|
|||||||
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