1
0
mirror of git://f0xx.org/ac/ac-ms-graphs synced 2026-07-29 03:39:30 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:29:33 +02:00
commit 224dc80d6d
10 changed files with 727 additions and 0 deletions

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# ac-ms-graphs
Microservice API. Remote: `git://f0xx.org/ac/ac-ms-graphs`

25
composer.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "androidcast/ms-graphs",
"description": "Graph session ingest/query",
"type": "project",
"require": {
"php": ">=8.1",
"androidcast/platform-php": "dev-next",
"androidcast/platform-db": "dev-next"
},
"repositories": [
{
"type": "vcs",
"url": "git://f0xx.org/ac/ac-platform-php"
},
{
"type": "vcs",
"url": "git://f0xx.org/ac/ac-platform-db"
}
],
"autoload": {
"classmap": [
"src/"
]
}
}

View File

@@ -0,0 +1,2 @@
<?php
return ['db' => ['driver' => 'sqlite']];

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

22
public/api/graphs.php Normal file
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);
}

2
public/index.php Normal file
View File

@@ -0,0 +1,2 @@
<?php
// API entry — wire nginx to public/

View File

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

View File

@@ -0,0 +1,6 @@
-- SQLite graph_sessions indexes (table may already exist via GraphRepository::ensureSchema).
CREATE INDEX IF NOT EXISTS idx_graph_company_time ON graph_sessions (company_id, started_at_ms);
CREATE INDEX IF NOT EXISTS idx_graph_user_time ON graph_sessions (user_id, started_at_ms);
CREATE INDEX IF NOT EXISTS idx_graph_started ON graph_sessions (started_at_ms);
CREATE INDEX IF NOT EXISTS idx_graph_device ON graph_sessions (device_id);

View File

@@ -0,0 +1,5 @@
-- Extra graph_sessions indexes for graphs dashboard (user scope + platform-wide time range).
-- Idempotent on MariaDB 10.5+: duplicate index names are ignored manually when re-run.
CREATE INDEX IF NOT EXISTS idx_graph_user_time ON graph_sessions (user_id, started_at_ms);
CREATE INDEX IF NOT EXISTS idx_graph_started ON graph_sessions (started_at_ms);

600
src/GraphRepository.php Normal file
View File

@@ -0,0 +1,600 @@
<?php
declare(strict_types=1);
final class GraphRepository {
private static bool $schemaEnsured = false;
public static function ensureSchema(): void {
if (self::$schemaEnsured) {
return;
}
$pdo = Database::pdo();
Database::withMigrationLock($pdo, 'graph_sessions', static function () use ($pdo): void {
if (!Database::tableExists($pdo, 'graph_sessions')) {
self::createTable($pdo);
self::ensureIndexes($pdo);
return;
}
self::ensureColumns($pdo);
self::ensureIndexes($pdo);
});
self::$schemaEnsured = true;
}
private static function columnExists(PDO $pdo, string $table, string $column): bool {
if (Database::isMysql()) {
$stmt = $pdo->prepare(
'SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1'
);
$stmt->execute([$table, $column]);
return (bool) $stmt->fetchColumn();
}
$stmt = $pdo->query('PRAGMA table_info(' . $table . ')');
if ($stmt === false) {
return false;
}
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (($row['name'] ?? '') === $column) {
return true;
}
}
return false;
}
private static function indexExists(PDO $pdo, string $table, string $index): bool {
if (Database::isMysql()) {
$stmt = $pdo->prepare(
'SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1'
);
$stmt->execute([$table, $index]);
return (bool) $stmt->fetchColumn();
}
$stmt = $pdo->query('PRAGMA index_list(' . $table . ')');
if ($stmt === false) {
return false;
}
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (($row['name'] ?? '') === $index) {
return true;
}
}
return false;
}
private static function createTable(PDO $pdo): void {
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,
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"
);
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,
device_id TEXT NULL,
app_version TEXT NULL,
sdk_int INTEGER NULL,
started_at_ms INTEGER NOT NULL,
ended_at_ms INTEGER NOT NULL,
duration_s INTEGER NOT NULL,
completed INTEGER NOT NULL DEFAULT 1,
direction TEXT NOT NULL DEFAULT 'sender',
transport TEXT NOT NULL DEFAULT 'wifi',
avg_kbps INTEGER NOT NULL DEFAULT 0,
recv_kbps INTEGER NOT NULL DEFAULT 0,
packet_loss_pct REAL NOT NULL DEFAULT 0.0,
ntp_source TEXT NOT NULL DEFAULT 'device',
ntp_correction_s INTEGER NULL,
install_source TEXT NOT NULL DEFAULT 'direct',
meta_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
}
private static function ensureIndexes(PDO $pdo): void {
$defs = Database::isMysql()
? [
'idx_graph_user_time' => 'CREATE INDEX idx_graph_user_time ON graph_sessions (user_id, started_at_ms)',
'idx_graph_started' => 'CREATE INDEX idx_graph_started ON graph_sessions (started_at_ms)',
]
: [
'idx_graph_company_time' => 'CREATE INDEX IF NOT EXISTS idx_graph_company_time ON graph_sessions (company_id, started_at_ms)',
'idx_graph_user_time' => 'CREATE INDEX IF NOT EXISTS idx_graph_user_time ON graph_sessions (user_id, started_at_ms)',
'idx_graph_started' => 'CREATE INDEX IF NOT EXISTS idx_graph_started ON graph_sessions (started_at_ms)',
'idx_graph_device' => 'CREATE INDEX IF NOT EXISTS idx_graph_device ON graph_sessions (device_id)',
];
foreach ($defs as $name => $sql) {
if (self::indexExists($pdo, 'graph_sessions', $name)) {
continue;
}
try {
$pdo->exec($sql);
} catch (PDOException $e) {
$msg = strtolower($e->getMessage());
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
throw $e;
}
}
}
}
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) {
if (self::columnExists($pdo, 'graph_sessions', $name)) {
continue;
}
try {
$pdo->exec('ALTER TABLE graph_sessions ADD COLUMN ' . $name . ' ' . $ddl);
} catch (PDOException $e) {
$msg = strtolower($e->getMessage());
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
throw $e;
}
}
}
}
public static function insertSession(array $in): void {
self::ensureSchema();
$pdo = Database::pdo();
$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'] ?? ($in['time_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));
$recvKbps = max(0, (int) ($in['recv_kbps'] ?? 0));
$loss = max(0.0, (float) ($in['packet_loss_pct'] ?? 0.0));
$companyId = (int) ($in['company_id'] ?? Rbac::defaultCompanyId());
if ($companyId <= 0) {
$companyId = Rbac::defaultCompanyId();
}
$userId = isset($in['user_id']) ? (int) $in['user_id'] : null;
if ($userId !== null && $userId <= 0) {
$userId = null;
}
$deviceId = trim((string) ($in['device_id'] ?? ''));
if ($deviceId === '' && is_array($in['device'] ?? null)) {
$deviceId = trim((string) (($in['device']['id'] ?? $in['device']['device_id'] ?? '')));
}
$appVersion = trim((string) ($in['app_version'] ?? ''));
if ($appVersion === '' && is_array($in['app'] ?? null)) {
$appVersion = trim((string) ($in['app']['version_name'] ?? $in['app']['version'] ?? ''));
}
$sdkInt = isset($in['sdk_int']) ? (int) $in['sdk_int'] : null;
if ($sdkInt === null && is_array($in['device'] ?? null)) {
$sdkInt = isset($in['device']['sdk_int']) ? (int) $in['device']['sdk_int'] : null;
}
$completed = 1;
if (array_key_exists('completed', $in)) {
$completed = !empty($in['completed']) ? 1 : 0;
}
$ntpCorrection = isset($in['ntp_correction_s']) ? (int) $in['ntp_correction_s'] : null;
if ($ntpCorrection === null && isset($in['ntp_correction_seconds'])) {
$ntpCorrection = (int) $in['ntp_correction_seconds'];
}
$meta = $in['meta'] ?? null;
$metaJson = is_array($meta) ? safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null;
if (Database::isMysql()) {
$sql = 'INSERT INTO graph_sessions
(session_id, company_id, user_id, device_id, app_version, sdk_int,
started_at_ms, ended_at_ms, duration_s, completed, direction, transport,
avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
ended_at_ms = VALUES(ended_at_ms),
duration_s = VALUES(duration_s),
completed = VALUES(completed),
avg_kbps = VALUES(avg_kbps),
recv_kbps = VALUES(recv_kbps),
packet_loss_pct = VALUES(packet_loss_pct),
ntp_source = VALUES(ntp_source),
ntp_correction_s = VALUES(ntp_correction_s),
install_source = VALUES(install_source),
device_id = COALESCE(VALUES(device_id), device_id),
app_version = COALESCE(VALUES(app_version), app_version),
sdk_int = COALESCE(VALUES(sdk_int), sdk_int),
meta_json = VALUES(meta_json)';
} else {
$sql = 'INSERT OR REPLACE INTO graph_sessions
(session_id, company_id, user_id, device_id, app_version, sdk_int,
started_at_ms, ended_at_ms, duration_s, completed, direction, transport,
avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
}
$stmt = $pdo->prepare($sql);
$stmt->execute([
$sessionId,
$companyId,
$userId,
$deviceId !== '' ? $deviceId : null,
$appVersion !== '' ? $appVersion : null,
$sdkInt,
$start,
$end,
$duration,
$completed,
$dir,
$transport,
$avgKbps,
$recvKbps,
$loss,
$ntp,
$ntpCorrection,
$install,
$metaJson,
]);
}
public static function dashboardData(int $days = 14): array {
self::ensureSchema();
$days = max(1, min(60, $days));
$user = Auth::user();
$uid = (int) ($user['id'] ?? 0);
$scopeUserId = $uid > 0 ? $uid : null;
$activeCompanyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
$viewer = self::viewerRole($user);
$payload = [
'window_days' => $days,
'viewer' => $viewer,
'links' => self::serviceLinks(),
];
$payload['live_cast'] = LiveCastRepository::analytics($days, $user ?? []);
$payload['user'] = self::buildScope($days, $activeCompanyId, null);
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/',
'issues' => $prefix . '/issues/?view=reports',
'tickets' => $prefix . '/issues/?view=tickets',
'build' => $prefix . '/build/',
'git' => $prefix . '/git/',
];
}
private static function buildScope(int $days, ?int $companyId, ?int $userId, bool $platform = false): array {
$startMs = (int) ((time() - ($days * 86400)) * 1000);
$scope = [
'sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId),
'unique_devices_per_day' => self::uniqueDevicesPerDay($startMs, $companyId, $userId),
'avg_duration_s_per_day' => self::avgDurationPerDay($startMs, $companyId, $userId),
'send_sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId, 'sender'),
'recv_sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId, 'receiver'),
'success_ratio_pct' => self::successRatio($startMs, $companyId, $userId),
'avg_bitrate_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId, 'avg_kbps'),
'avg_recv_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId, 'recv_kbps'),
'crashes_per_day' => self::crashesPerDay($startMs, $companyId),
'ntp_sources' => self::breakdown('ntp_source', $startMs, $companyId, $userId),
'install_sources' => self::breakdown('install_source', $startMs, $companyId, $userId),
'transport_mix' => self::breakdown('transport', $startMs, $companyId, $userId),
'app_versions' => self::breakdown('app_version', $startMs, $companyId, $userId),
];
if (Database::tableExists(Database::pdo(), 'tickets')) {
$scope['tickets_per_day'] = self::ticketsPerDay($startMs, $companyId);
}
if ($platform) {
$scope['top_crash_fingerprints'] = self::topCrashFingerprints($startMs, 8);
$scope['crashes_by_app_version'] = self::crashesByAppVersion($startMs);
$scope['ntp_correction_avg_s'] = self::avgNtpCorrection($startMs, $companyId, $userId);
}
return $scope;
}
private static function sessionsPerDay(
int $startMs,
?int $companyId,
?int $userId,
?string $direction = null
): array {
$where = ['started_at_ms >= ?'];
$params = [$startMs];
if ($direction !== null) {
$where[] = 'direction = ?';
$params[] = $direction;
}
self::appendScope($where, $params, $companyId, $userId);
return self::countByDay('graph_sessions', 'started_at_ms', $where, $params);
}
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);
$day = self::msDayExpr('started_at_ms');
$sql = 'SELECT ' . $day . ' AS d, COUNT(DISTINCT device_id) AS c FROM graph_sessions WHERE '
. implode(' AND ', $where) . ' GROUP BY d ORDER BY d';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$bucket = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$bucket[(string) ($row['d'] ?? '')] = (int) ($row['c'] ?? 0);
}
return self::assocToSeries($bucket);
}
private static function avgDurationPerDay(int $startMs, ?int $companyId, ?int $userId): array {
return self::avgNumericPerDay($startMs, $companyId, $userId, 'duration_s');
}
private static function successRatio(int $startMs, ?int $companyId, ?int $userId): float {
$where = ['started_at_ms >= ?'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT COUNT(*) AS total,
SUM(CASE WHEN completed = 1 THEN 1 ELSE 0 END) AS ok
FROM graph_sessions WHERE ' . implode(' AND ', $where);
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
$total = (int) ($row['total'] ?? 0);
$ok = (int) ($row['ok'] ?? 0);
if ($total <= 0) {
return 0.0;
}
return round(100.0 * $ok / $total, 1);
}
private static function avgBitratePerDay(
int $startMs,
?int $companyId,
?int $userId,
string $column
): array {
$allowed = ['avg_kbps', 'recv_kbps'];
if (!in_array($column, $allowed, true)) {
return [];
}
return self::avgNumericPerDay($startMs, $companyId, $userId, $column);
}
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;
}
return self::countByDay('reports', 'received_at_ms', $where, $params);
}
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;
}
return self::countByDay('tickets', 'opened_at_ms', $where, $params);
}
private static function topCrashFingerprints(int $startMs, int $limit): array {
if (!Database::tableExists(Database::pdo(), 'reports')) {
return [];
}
$sql = 'SELECT fingerprint, COUNT(*) AS c, MAX(id) AS last_id
FROM reports WHERE received_at_ms >= ?
GROUP BY fingerprint ORDER BY c DESC LIMIT ' . (int) $limit;
$stmt = Database::pdo()->prepare($sql);
$stmt->execute([$startMs]);
$base = Auth::basePath();
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$fp = (string) ($row['fingerprint'] ?? '');
$id = (int) ($row['last_id'] ?? 0);
$out[] = [
'label' => $fp,
'value' => (int) ($row['c'] ?? 0),
'href' => $id > 0 ? $base . '/?view=report&id=' . $id : null,
];
}
return $out;
}
private static function crashesByAppVersion(int $startMs): array {
if (!Database::tableExists(Database::pdo(), 'reports')) {
return [];
}
$sql = "SELECT COALESCE(NULLIF(app_version, ''), 'unknown') AS k, COUNT(*) AS c
FROM reports WHERE received_at_ms >= ?
GROUP BY k ORDER BY c DESC LIMIT 12";
$stmt = Database::pdo()->prepare($sql);
$stmt->execute([$startMs]);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
}
return $out;
}
private static function avgNtpCorrection(int $startMs, ?int $companyId, ?int $userId): ?float {
$where = ['started_at_ms >= ?', 'ntp_correction_s IS NOT NULL'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT AVG(ABS(ntp_correction_s)) AS avg_s FROM graph_sessions WHERE '
. implode(' AND ', $where);
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$v = $stmt->fetchColumn();
return $v === false || $v === null ? null : round((float) $v, 2);
}
private static function breakdown(string $column, int $startMs, ?int $companyId, ?int $userId): array {
$allowed = ['ntp_source', 'install_source', 'transport', 'app_version'];
if (!in_array($column, $allowed, true)) {
return [];
}
$where = ['started_at_ms >= ?'];
$params = [$startMs];
if ($column === 'app_version') {
$where[] = "app_version IS NOT NULL";
$where[] = "app_version <> ''";
}
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT ' . $column . ' AS k, COUNT(*) AS c FROM graph_sessions WHERE '
. implode(' AND ', $where) . ' GROUP BY ' . $column . ' ORDER BY c DESC';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$label = (string) ($row['k'] ?? 'unknown');
if ($label === '') {
$label = 'unknown';
}
$out[] = ['label' => $label, 'value' => (int) ($row['c'] ?? 0)];
}
return $out;
}
private static function msDayExpr(string $msColumn): string {
if (Database::isMysql()) {
return 'DATE(FROM_UNIXTIME(FLOOR(' . $msColumn . ' / 1000)))';
}
return "strftime('%Y-%m-%d', " . $msColumn . " / 1000, 'unixepoch')";
}
private static function countByDay(string $table, string $msColumn, array $where, array $params): array {
$day = self::msDayExpr($msColumn);
$sql = 'SELECT ' . $day . ' AS d, COUNT(*) AS c FROM ' . $table . ' WHERE '
. implode(' AND ', $where) . ' GROUP BY d ORDER BY d';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$bucket = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$bucket[(string) ($row['d'] ?? '')] = (int) ($row['c'] ?? 0);
}
return self::assocToSeries($bucket);
}
private static function avgNumericPerDay(
int $startMs,
?int $companyId,
?int $userId,
string $column
): array {
$where = ['started_at_ms >= ?'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$day = self::msDayExpr('started_at_ms');
$sql = 'SELECT ' . $day . ' AS d, AVG(' . $column . ') AS v FROM graph_sessions WHERE '
. implode(' AND ', $where) . ' GROUP BY d ORDER BY d';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$avg = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$avg[(string) ($row['d'] ?? '')] = round((float) ($row['v'] ?? 0), 1);
}
return self::assocToSeries($avg);
}
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;
}
}