mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:58:51 +03:00
stage 1
This commit is contained in:
@@ -211,6 +211,31 @@ MariaDB (**required on existing servers**): `mysql -u root -p androidcast_crashe
|
||||
|
||||
**Attachments (005):** `mysql -u root -p androidcast_crashes < sql/migrations/005_ticket_attachments.sql` — files + external links (Google URLs auto-labeled).
|
||||
|
||||
## Graphs dashboard (`/graphs`)
|
||||
|
||||
Stage-1 custom BE graphs are available at:
|
||||
|
||||
- `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).
|
||||
|
||||
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)
|
||||
|
||||
Fake data helper scripts:
|
||||
|
||||
```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`.
|
||||
|
||||
## Default accounts
|
||||
|
||||
| User | Password | Role |
|
||||
|
||||
@@ -18,6 +18,10 @@ server {
|
||||
return 301 /app/androidcast_project/crashes/;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs {
|
||||
return 301 /app/androidcast_project/graphs/;
|
||||
}
|
||||
|
||||
location ^~ /app/androidcast_project/crashes/assets/ {
|
||||
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
|
||||
}
|
||||
@@ -40,6 +44,32 @@ server {
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs/api/graphs.php {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs/api/graph_upload.php {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location ^~ /app/androidcast_project/graphs/ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/build {
|
||||
return 301 /app/androidcast_project/build/;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
return 301 /app/androidcast_project/crashes/;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs {
|
||||
return 301 /app/androidcast_project/graphs/;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project {
|
||||
return 301 /app/androidcast_project/;
|
||||
}
|
||||
@@ -68,3 +72,29 @@
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs/api/graphs.php {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs/api/graph_upload.php {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location ^~ /app/androidcast_project/graphs/ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ server {
|
||||
return 301 /app/androidcast_project/crashes/;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs {
|
||||
return 301 /app/androidcast_project/graphs/;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project {
|
||||
return 301 /app/androidcast_project/;
|
||||
}
|
||||
@@ -53,6 +57,32 @@ server {
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs/api/graphs.php {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs/api/graph_upload.php {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location ^~ /app/androidcast_project/graphs/ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
|
||||
32
examples/crash_reporter/backend/public/api/graph_upload.php
Normal file
32
examples/crash_reporter/backend/public/api/graph_upload.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || $raw === '') {
|
||||
json_out(['ok' => false, 'error' => 'empty body'], 400);
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||
}
|
||||
if ((int) ($data['schema_version'] ?? 0) !== 1) {
|
||||
json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400);
|
||||
}
|
||||
if (($data['ingest_kind'] ?? 'graph_session') !== 'graph_session') {
|
||||
json_out(['ok' => false, 'error' => 'ingest_kind must be graph_session'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
GraphRepository::insertSession($data);
|
||||
json_out(['ok' => true, 'session_id' => $data['session_id'] ?? null]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
error_log('graph upload: ' . $e->getMessage());
|
||||
$out = ['ok' => false, 'error' => 'store_failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $e->getMessage();
|
||||
}
|
||||
json_out($out, 500);
|
||||
}
|
||||
22
examples/crash_reporter/backend/public/api/graphs.php
Normal file
22
examples/crash_reporter/backend/public/api/graphs.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
Auth::check();
|
||||
|
||||
$days = (int) ($_GET['days'] ?? 14);
|
||||
if ($days <= 0) {
|
||||
$days = 14;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = GraphRepository::dashboardData($days);
|
||||
json_out(['ok' => true, 'data' => $payload]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('graphs api: ' . $e->getMessage());
|
||||
$out = ['ok' => false, 'error' => 'graphs_load_failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $e->getMessage();
|
||||
}
|
||||
json_out($out, 500);
|
||||
}
|
||||
@@ -237,6 +237,29 @@ a:hover { text-decoration: underline; }
|
||||
transform-origin: left bottom;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.nav-icon--graphs {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-left: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.ticket-col-issue { min-width: 200px; max-width: 420px; }
|
||||
.ticket-issue-title { font-weight: 600; }
|
||||
.ticket-issue-brief { font-size: 0.88em; margin-top: 4px; }
|
||||
@@ -916,6 +939,14 @@ a:hover { text-decoration: underline; }
|
||||
.exc { color: var(--danger); font-weight: 600; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin: 20px 0; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
|
||||
.card canvas {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 180px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface2);
|
||||
}
|
||||
.cards--lift .card--lift {
|
||||
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
|
||||
}
|
||||
|
||||
109
examples/crash_reporter/backend/public/assets/js/graphs.js
Normal file
109
examples/crash_reporter/backend/public/assets/js/graphs.js
Normal file
@@ -0,0 +1,109 @@
|
||||
(function () {
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function drawLineChart(canvas, series, color) {
|
||||
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);
|
||||
ctx.fillStyle = 'rgba(90,110,140,0.15)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
const points = Array.isArray(series) ? series : [];
|
||||
if (!points.length) 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;
|
||||
|
||||
ctx.strokeStyle = 'rgba(120,140,170,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = pad + (plotH * i) / 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad, y);
|
||||
ctx.lineTo(w - pad, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = color || '#3d8bfd';
|
||||
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;
|
||||
if (idx === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function renderBreakdown(el, rows) {
|
||||
if (!el) return;
|
||||
if (!Array.isArray(rows) || !rows.length) {
|
||||
el.textContent = 'No data';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = rows
|
||||
.map((r) => `<div><strong>${String(r.label || '')}</strong>: ${Number(r.value || 0)}</div>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function bootGraphs() {
|
||||
const app = document.getElementById('graphs-app');
|
||||
if (!app) return;
|
||||
const status = document.getElementById('graphs-status');
|
||||
const daysSel = document.getElementById('graphs-days');
|
||||
|
||||
function load() {
|
||||
const days = Number(daysSel && daysSel.value ? daysSel.value : 14);
|
||||
status.textContent = 'Loading...';
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', basePath() + '/api/graphs.php?days=' + encodeURIComponent(String(days)), true);
|
||||
xhr.onload = function () {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
status.textContent = 'Invalid response';
|
||||
return;
|
||||
}
|
||||
if (!payload || !payload.ok) {
|
||||
status.textContent = (payload && payload.error) || 'Failed';
|
||||
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';
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
status.textContent = 'Network error';
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
if (daysSel) {
|
||||
daysSel.addEventListener('change', load);
|
||||
}
|
||||
load();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bootGraphs);
|
||||
} else {
|
||||
bootGraphs();
|
||||
}
|
||||
})();
|
||||
@@ -85,6 +85,16 @@ if ($route === '/api/ticket_attachments.php' || str_ends_with($route, '/api/tick
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/graphs.php' || str_ends_with($route, '/api/graphs.php') || str_ends_with($route, '/graphs/api/graphs.php')) {
|
||||
require __DIR__ . '/api/graphs.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/graph_upload.php' || str_ends_with($route, '/api/graph_upload.php') || str_ends_with($route, '/graphs/api/graph_upload.php')) {
|
||||
require __DIR__ . '/api/graph_upload.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) {
|
||||
require __DIR__ . '/api/ticket_attachment.php';
|
||||
exit;
|
||||
@@ -118,6 +128,11 @@ if ($route === '/login') {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/graphs' || $route === '/graphs/' || str_ends_with($route, '/app/androidcast_project/graphs') || str_ends_with($route, '/app/androidcast_project/graphs/')) {
|
||||
$_GET['view'] = 'graphs';
|
||||
$route = '/';
|
||||
}
|
||||
|
||||
Auth::check();
|
||||
|
||||
$grouped = isset($_GET['group']) && $_GET['group'] === '1';
|
||||
@@ -176,6 +191,7 @@ if ($view === 'ticket' && isset($_GET['id'])) {
|
||||
$pageTitle = match ($view) {
|
||||
'home' => 'Home',
|
||||
'tickets' => 'Tickets',
|
||||
'graphs' => 'Graphs',
|
||||
'reports', 'report' => 'Crash reports',
|
||||
default => 'Console',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
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');
|
||||
$companyId = (int) ($opts['company'] ?? 1);
|
||||
$userId = (int) ($opts['user'] ?? 1);
|
||||
|
||||
$nowMs = (int) round(microtime(true) * 1000);
|
||||
$startFloorMs = $nowMs - ($days * 86400 * 1000);
|
||||
$transports = ['wifi', 'cellular', 'ethernet'];
|
||||
$installs = ['direct', 'play_store', 'mdm', 'partner'];
|
||||
$ntp = ['device', 'backend', 'pool.ntp.org'];
|
||||
|
||||
$fh = fopen($out, 'wb');
|
||||
if (!$fh) {
|
||||
fwrite(STDERR, "Cannot write $out\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$start = random_int($startFloorMs, $nowMs - 120000);
|
||||
$duration = random_int(120, 9000);
|
||||
$end = $start + ($duration * 1000);
|
||||
$payload = [
|
||||
'schema_version' => 1,
|
||||
'ingest_kind' => 'graph_session',
|
||||
'session_id' => 'fake-' . gmdate('Ymd', (int) ($start / 1000)) . '-' . substr(sha1((string) ($start . ':' . $i)), 0, 12),
|
||||
'company_id' => $companyId,
|
||||
'user_id' => $userId,
|
||||
'started_at_epoch_ms' => $start,
|
||||
'ended_at_epoch_ms' => $end,
|
||||
'duration_s' => $duration,
|
||||
'direction' => ($i % 3 === 0) ? 'receiver' : 'sender',
|
||||
'transport' => $transports[array_rand($transports)],
|
||||
'avg_kbps' => random_int(640, 12000),
|
||||
'packet_loss_pct' => round(mt_rand(0, 600) / 100, 2),
|
||||
'ntp_source' => $ntp[array_rand($ntp)],
|
||||
'install_source' => $installs[array_rand($installs)],
|
||||
'meta' => ['fake' => true, 'seed' => $i],
|
||||
];
|
||||
fwrite($fh, json_encode($payload, JSON_UNESCAPED_SLASHES) . "\n");
|
||||
}
|
||||
fclose($fh);
|
||||
echo "Generated $count rows into $out\n";
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
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', '/');
|
||||
$dryRun = isset($opts['dry-run']);
|
||||
$api = $base . '/api/graph_upload.php';
|
||||
|
||||
if (!is_readable($file)) {
|
||||
fwrite(STDERR, "File not found: $file\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
foreach ($lines as $i => $line) {
|
||||
$payload = json_decode($line, true);
|
||||
if (!is_array($payload)) {
|
||||
$fail++;
|
||||
continue;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$ok++;
|
||||
continue;
|
||||
}
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\n",
|
||||
'content' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
'ignore_errors' => true,
|
||||
'timeout' => 20,
|
||||
],
|
||||
]);
|
||||
$resp = @file_get_contents($api, false, $ctx);
|
||||
$code = 0;
|
||||
if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
|
||||
$code = (int) $m[1];
|
||||
}
|
||||
if ($code >= 200 && $code < 300) {
|
||||
$ok++;
|
||||
} else {
|
||||
$fail++;
|
||||
fwrite(STDERR, "FAIL line " . ($i + 1) . " http=$code resp=$resp\n");
|
||||
}
|
||||
}
|
||||
|
||||
echo "Done: ok=$ok fail=$fail total=" . count($lines) . "\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
254
examples/crash_reporter/backend/src/GraphRepository.php
Normal file
254
examples/crash_reporter/backend/src/GraphRepository.php
Normal file
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class GraphRepository {
|
||||
public static function ensureSchema(): void {
|
||||
$pdo = Database::pdo();
|
||||
if (Database::tableExists($pdo, 'graph_sessions')) {
|
||||
return;
|
||||
}
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"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,
|
||||
started_at_ms BIGINT NOT NULL,
|
||||
ended_at_ms BIGINT NOT NULL,
|
||||
duration_s INT NOT NULL,
|
||||
direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender',
|
||||
transport VARCHAR(24) NOT NULL DEFAULT 'wifi',
|
||||
avg_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',
|
||||
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)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
return;
|
||||
}
|
||||
$pdo->exec(
|
||||
"CREATE TABLE IF NOT EXISTS graph_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
company_id INTEGER NOT NULL DEFAULT 1,
|
||||
user_id INTEGER NULL,
|
||||
started_at_ms INTEGER NOT NULL,
|
||||
ended_at_ms INTEGER NOT NULL,
|
||||
duration_s INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL DEFAULT 'sender',
|
||||
transport TEXT NOT NULL DEFAULT 'wifi',
|
||||
avg_kbps INTEGER NOT NULL DEFAULT 0,
|
||||
packet_loss_pct REAL NOT NULL DEFAULT 0.0,
|
||||
ntp_source TEXT NOT NULL DEFAULT 'device',
|
||||
install_source TEXT NOT NULL DEFAULT 'direct',
|
||||
meta_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
}
|
||||
|
||||
public static function insertSession(array $in): void {
|
||||
self::ensureSchema();
|
||||
$pdo = Database::pdo();
|
||||
$sessionId = trim((string) ($in['session_id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
throw new InvalidArgumentException('session_id is required');
|
||||
}
|
||||
$start = (int) ($in['started_at_epoch_ms'] ?? 0);
|
||||
$end = (int) ($in['ended_at_epoch_ms'] ?? 0);
|
||||
if ($start <= 0 || $end <= 0 || $end < $start) {
|
||||
throw new InvalidArgumentException('invalid session timing');
|
||||
}
|
||||
$dir = strtolower(trim((string) ($in['direction'] ?? 'sender')));
|
||||
if ($dir !== 'sender' && $dir !== 'receiver') {
|
||||
$dir = 'sender';
|
||||
}
|
||||
$transport = strtolower(trim((string) ($in['transport'] ?? 'wifi')));
|
||||
if ($transport === '') {
|
||||
$transport = 'wifi';
|
||||
}
|
||||
$ntp = strtolower(trim((string) ($in['ntp_source'] ?? 'device')));
|
||||
if ($ntp === '') {
|
||||
$ntp = 'device';
|
||||
}
|
||||
$install = strtolower(trim((string) ($in['install_source'] ?? 'direct')));
|
||||
if ($install === '') {
|
||||
$install = 'direct';
|
||||
}
|
||||
$duration = max(0, (int) ($in['duration_s'] ?? (int) (($end - $start) / 1000)));
|
||||
$avgKbps = max(0, (int) ($in['avg_kbps'] ?? 0));
|
||||
$loss = max(0.0, (float) ($in['packet_loss_pct'] ?? 0.0));
|
||||
$companyId = (int) ($in['company_id'] ?? Rbac::defaultCompanyId());
|
||||
if ($companyId <= 0) {
|
||||
$companyId = Rbac::defaultCompanyId();
|
||||
}
|
||||
$userId = isset($in['user_id']) ? (int) $in['user_id'] : null;
|
||||
if ($userId !== null && $userId <= 0) {
|
||||
$userId = null;
|
||||
}
|
||||
$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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
ended_at_ms = VALUES(ended_at_ms),
|
||||
duration_s = VALUES(duration_s),
|
||||
avg_kbps = VALUES(avg_kbps),
|
||||
packet_loss_pct = VALUES(packet_loss_pct),
|
||||
ntp_source = VALUES(ntp_source),
|
||||
install_source = VALUES(install_source),
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
||||
}
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
$sessionId,
|
||||
$companyId,
|
||||
$userId,
|
||||
$start,
|
||||
$end,
|
||||
$duration,
|
||||
$dir,
|
||||
$transport,
|
||||
$avgKbps,
|
||||
$loss,
|
||||
$ntp,
|
||||
$install,
|
||||
$metaJson,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function dashboardData(int $days = 14): array {
|
||||
self::ensureSchema();
|
||||
$days = max(3, min(60, $days));
|
||||
$user = Auth::user();
|
||||
$uid = (int) ($user['id'] ?? 0);
|
||||
$scopeUserId = $uid > 0 ? $uid : null;
|
||||
$activeCompanyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
|
||||
|
||||
return [
|
||||
'window_days' => $days,
|
||||
'user' => self::buildScope($days, $activeCompanyId, $scopeUserId),
|
||||
'slug_admin' => self::buildScope($days, $activeCompanyId, null),
|
||||
'platform_admin' => self::buildScope($days, null, null),
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildScope(int $days, ?int $companyId, ?int $userId): array {
|
||||
$startMs = (int) ((time() - ($days * 86400)) * 1000);
|
||||
return [
|
||||
'sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId),
|
||||
'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),
|
||||
];
|
||||
}
|
||||
|
||||
private static function sessionsPerDay(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';
|
||||
$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);
|
||||
$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 breakdown(string $column, int $startMs, ?int $companyId, ?int $userId): array {
|
||||
$allowed = ['ntp_source', 'install_source', 'transport'];
|
||||
if (!in_array($column, $allowed, true)) {
|
||||
return [];
|
||||
}
|
||||
$where = ['started_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
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)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function appendScope(array &$where, array &$params, ?int $companyId, ?int $userId): void {
|
||||
if ($companyId !== null && $companyId > 0) {
|
||||
$where[] = 'company_id = ?';
|
||||
$params[] = $companyId;
|
||||
}
|
||||
if ($userId !== null && $userId > 0) {
|
||||
$where[] = 'user_id = ?';
|
||||
$params[] = $userId;
|
||||
}
|
||||
}
|
||||
|
||||
private static function assocToSeries(array $assoc): array {
|
||||
ksort($assoc);
|
||||
$out = [];
|
||||
foreach ($assoc as $k => $v) {
|
||||
$out[] = ['x' => $k, 'y' => $v];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ require_once __DIR__ . '/TicketAttachmentRepository.php';
|
||||
require_once __DIR__ . '/TicketCommentRepository.php';
|
||||
require_once __DIR__ . '/UserRepository.php';
|
||||
require_once __DIR__ . '/TicketRepository.php';
|
||||
require_once __DIR__ . '/GraphRepository.php';
|
||||
require_once __DIR__ . '/AnalyticsHead.php';
|
||||
|
||||
function cfg(string $key, $default = null) {
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
<?php if (in_array($view ?? '', ['tickets', 'ticket'], true)): ?>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/tickets.js" defer></script>
|
||||
<?php endif; ?>
|
||||
<?php if (($view ?? '') === 'graphs'): ?>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/graphs.js" defer></script>
|
||||
<?php endif; ?>
|
||||
<?php AnalyticsHead::render('crashes'); ?>
|
||||
</head>
|
||||
<body data-base-path="<?= h(Auth::basePath()) ?>"
|
||||
@@ -75,6 +78,15 @@
|
||||
<span class="nav-label" data-i18n="nav.tickets">Tickets</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/app/androidcast_project/graphs/"
|
||||
class="nav-link <?= ($view ?? '') === 'graphs' ? 'active' : '' ?>"
|
||||
aria-label="Graphs"
|
||||
title="Graphs">
|
||||
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
|
||||
<span class="nav-label">Graphs</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/app/androidcast_project/build/"
|
||||
class="nav-link"
|
||||
@@ -222,6 +234,60 @@
|
||||
</div>
|
||||
<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 class="toolbar reports-toolbar">
|
||||
<h1>AndroidCast graphs</h1>
|
||||
<div class="toolbar-actions">
|
||||
<label class="toolbar-select">
|
||||
<span>Theme</span>
|
||||
<select id="theme-select" aria-label="UI theme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="toolbar-select">
|
||||
<span>Window</span>
|
||||
<select id="graphs-days" aria-label="Graphs window">
|
||||
<option value="7">7d</option>
|
||||
<option value="14" selected>14d</option>
|
||||
<option value="30">30d</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
<code><?= h(Auth::basePath()) ?>/api/graph_upload.php</code>
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user