1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 06:58:51 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-02 15:24:11 +02:00
parent 9f9d617730
commit 5e940690e9
35 changed files with 896 additions and 41 deletions

View File

@@ -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 |

View File

@@ -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/;
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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);
}

View 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);
}

View File

@@ -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;
}

View 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();
}
})();

View File

@@ -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',
};

View File

@@ -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";

View File

@@ -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);

View 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;
}
}

View File

@@ -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) {

View File

@@ -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>