diff --git a/examples/app_hub/assets/i18n/hub-en.json b/examples/app_hub/assets/i18n/hub-en.json
index 2a68924..90271b9 100644
--- a/examples/app_hub/assets/i18n/hub-en.json
+++ b/examples/app_hub/assets/i18n/hub-en.json
@@ -17,11 +17,11 @@
"nav.remote": "Remote access",
"nav.short_links": "Short links",
"nav.short_links_desc": "Mint bearer tokens and shorten URLs for s.f0xx.org.",
- "nav.console_desc": "Crash triage home and workspace cards.",
+ "nav.console_desc": "Issue triage home and workspace cards.",
"nav.git_desc": "Browse repos, branches, and tags.",
- "nav.issues_desc": "Browse and triage crash reports.",
+ "nav.issues_desc": "Browse and triage issues.",
"nav.tickets_desc": "Roadmap tasks, QA items, and tags.",
- "nav.graphs_desc": "Sessions, crashes, and device activity.",
+ "nav.graphs_desc": "Sessions, issues, and device activity.",
"nav.remote_desc": "WireGuard sessions and device reachability.",
"nav.builder_desc": "Docker CI for APK baking and OTA artifacts.",
"nav.builder": "Builder",
diff --git a/examples/app_hub/assets/i18n/hub-ru.json b/examples/app_hub/assets/i18n/hub-ru.json
index 21492a1..965a125 100644
--- a/examples/app_hub/assets/i18n/hub-ru.json
+++ b/examples/app_hub/assets/i18n/hub-ru.json
@@ -19,9 +19,9 @@
"nav.short_links_desc": "Токены и сокращение URL для s.f0xx.org.",
"nav.console_desc": "Главная консоли и карточки разделов.",
"nav.git_desc": "Репозитории, ветки и теги.",
- "nav.issues_desc": "Просмотр и разбор отчётов о сбоях.",
+ "nav.issues_desc": "Просмотр и разбор issues.",
"nav.tickets_desc": "Задачи roadmap, QA и теги.",
- "nav.graphs_desc": "Сессии, сбои и активность устройств.",
+ "nav.graphs_desc": "Сессии, issues и активность устройств.",
"nav.remote_desc": "Сессии WireGuard и доступ к устройствам.",
"nav.builder_desc": "Docker CI для сборки APK и OTA.",
"nav.builder": "Сборщик",
diff --git a/examples/app_hub/landing-pages.inc.php b/examples/app_hub/landing-pages.inc.php
index 9a23e26..98e4051 100644
--- a/examples/app_hub/landing-pages.inc.php
+++ b/examples/app_hub/landing-pages.inc.php
@@ -17,7 +17,7 @@
Wi‑Fi and USB transport with adaptive bitrate and session metrics.
- Crashes
+ Issues
Ingest, triage tickets, and correlate fingerprints across releases.
diff --git a/examples/crash_reporter/backend/public/api/remote_access.php b/examples/crash_reporter/backend/public/api/remote_access.php
index 3248029..d9b4863 100644
--- a/examples/crash_reporter/backend/public/api/remote_access.php
+++ b/examples/crash_reporter/backend/public/api/remote_access.php
@@ -31,7 +31,10 @@ if ($method === 'GET') {
$inactive = array_values(array_filter($sessions, static fn($s) => !in_array($s['status'] ?? '', ['pending', 'active'], true)));
json_out([
'ok' => true,
- 'devices' => RemoteAccessRepository::listDevices(),
+ 'devices' => RemoteAccessRepository::listDevicesForDashboard(
+ rtrim(Auth::basePath(), '/'),
+ '/app/androidcast_project'
+ ),
'active_sessions' => $active,
'inactive_sessions' => array_slice($inactive, 0, 50),
'recent_events' => RemoteAccessRepository::listEvents(30),
diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css
index cf44f88..c22bc3f 100644
--- a/examples/crash_reporter/backend/public/assets/css/app.css
+++ b/examples/crash_reporter/backend/public/assets/css/app.css
@@ -547,6 +547,13 @@ a:hover { text-decoration: underline; }
padding: 24px 28px;
box-sizing: border-box;
-webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+.main-pane::-webkit-scrollbar {
+ display: none;
+ width: 0;
+ height: 0;
}
body { --footer-h: 40px; }
.bottom-bar {
@@ -2167,3 +2174,36 @@ button.report-tag--filter:hover {
.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; }
+
+.tag-pill {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ background: rgba(61, 139, 253, .18);
+ color: var(--accent);
+}
+.tag-pill--warn {
+ background: rgba(245, 158, 11, .18);
+ color: #fbbf24;
+}
+[data-theme="light"] .tag-pill--warn {
+ color: #b45309;
+}
+.ra-device-row--needs-wl {
+ background: rgba(245, 158, 11, .06);
+}
+.ra-device-actions {
+ white-space: nowrap;
+}
+.ra-detail-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 10px;
+}
+.ra-whitelist-hint {
+ max-width: 72ch;
+ line-height: 1.5;
+}
diff --git a/examples/crash_reporter/backend/public/assets/i18n/en.json b/examples/crash_reporter/backend/public/assets/i18n/en.json
index 2685063..204b525 100644
--- a/examples/crash_reporter/backend/public/assets/i18n/en.json
+++ b/examples/crash_reporter/backend/public/assets/i18n/en.json
@@ -6,10 +6,10 @@
"nav.logout": "Logout",
"nav.toggle": "Navigation",
"nav.home_desc": "Console home and workspace cards.",
- "nav.reports_desc": "Browse and triage crash reports.",
+ "nav.reports_desc": "Browse and triage issues.",
"nav.tickets_desc": "Roadmap tasks, QA items, and tags.",
"nav.graphs": "Graphs",
- "nav.graphs_desc": "Sessions, crashes, and device activity.",
+ "nav.graphs_desc": "Sessions, issues, and device activity.",
"nav.remote": "Remote access",
"nav.remote_desc": "WireGuard sessions and device reachability.",
"nav.access": "Access",
@@ -23,14 +23,14 @@
"lang.en": "English",
"lang.ru": "Russian",
"home.title": "Console",
- "home.intro": "Crash triage, tickets, and session metrics — pick a workspace below.",
+ "home.intro": "Issue triage, tickets, and session metrics — pick a workspace below.",
"home.card_issues": "Issues",
- "home.card_issues_desc": "Browse and triage crash reports, tags, and fingerprints.",
+ "home.card_issues_desc": "Browse and triage issues, tags, and fingerprints.",
"home.card_new_issue_desc": "Log a new issue or task in one step.",
"home.card_new_ticket_desc": "Create a roadmap or QA ticket in one step.",
"home.card_tickets_desc": "Roadmap tasks, QA items, and workflow tags.",
"home.card_graphs": "Graphs",
- "home.card_graphs_desc": "Sessions, crashes, and device activity over time.",
+ "home.card_graphs_desc": "Sessions, issues, and device activity over time.",
"home.card_short_links": "Short links",
"home.card_short_links_desc": "Mint bearer tokens and shorten URLs for s.f0xx.org.",
"home.more": "All services",
@@ -109,7 +109,7 @@
"tag.filter_and": "All tags (AND)",
"tag.filter_or": "Any tag (OR)",
"detail.back": "Back to list",
- "detail.title": "Crash report",
+ "detail.title": "Issue report",
"detail.report_meta": "Report {id} · {type}",
"detail.tags": "Tags",
"detail.device": "Device",
@@ -125,13 +125,13 @@
"detail.filter_version": "Show reports for this app version",
"detail.filter_build": "Show reports from this build",
"detail.java_exception": "Java exception",
- "detail.native_crash": "Native crash",
- "detail.find_similar": "Find similar reports",
+ "detail.native_crash": "Native issue",
+ "detail.find_similar": "Find similar issues",
"detail.thread": "Thread:",
"detail.signal": "Signal:",
- "detail.session_context": "Session context (crash)",
+ "detail.session_context": "Session context (issue)",
"detail.raw_json": "Raw JSON",
- "login.sign_in_hint": "Sign in to view anonymous crash reports",
+ "login.sign_in_hint": "Sign in to view anonymous issue reports",
"login.username": "Username",
"login.password": "Password",
"login.submit": "Sign in",
diff --git a/examples/crash_reporter/backend/public/assets/i18n/ru.json b/examples/crash_reporter/backend/public/assets/i18n/ru.json
index eda8815..b72d81c 100644
--- a/examples/crash_reporter/backend/public/assets/i18n/ru.json
+++ b/examples/crash_reporter/backend/public/assets/i18n/ru.json
@@ -16,10 +16,10 @@
"nav.logout": "Выход",
"nav.toggle": "Навигация",
"nav.home_desc": "Главная консоли и карточки разделов.",
- "nav.reports_desc": "Просмотр и разбор отчётов о сбоях.",
+ "nav.reports_desc": "Просмотр и разбор issues.",
"nav.tickets_desc": "Задачи roadmap, QA и теги.",
"nav.graphs": "Графики",
- "nav.graphs_desc": "Сессии, сбои и активность устройств.",
+ "nav.graphs_desc": "Сессии, issues и активность устройств.",
"nav.remote": "Удалённый доступ",
"nav.remote_desc": "Сессии WireGuard и доступ к устройствам.",
"nav.access": "Доступ",
@@ -35,12 +35,12 @@
"home.title": "Консоль",
"home.intro": "Сбои, задачи и метрики сессий — выберите раздел ниже.",
"home.card_issues": "Проблемы",
- "home.card_issues_desc": "Просмотр и разбор отчётов о сбоях, тегов и отпечатков.",
+ "home.card_issues_desc": "Просмотр и разбор issues, тегов и отпечатков.",
"home.card_new_issue_desc": "Создать новую проблему или задачу.",
"home.card_new_ticket_desc": "Создать тикет roadmap или QA за один шаг.",
"home.card_tickets_desc": "Задачи, QA и теги workflow.",
"home.card_graphs": "Графики",
- "home.card_graphs_desc": "Сессии, сбои и активность устройств.",
+ "home.card_graphs_desc": "Сессии, issues и активность устройств.",
"home.card_short_links": "Короткие ссылки",
"home.card_short_links_desc": "Токены и сокращение URL для s.f0xx.org.",
"home.more": "Все сервисы",
@@ -118,7 +118,7 @@
"tag.filter_and": "Все метки (И)",
"tag.filter_or": "Любая метка (ИЛИ)",
"detail.back": "К списку",
- "detail.title": "Отчёт о сбое",
+ "detail.title": "Issue-отчёт",
"detail.report_meta": "Отчёт {id} · {type}",
"detail.tags": "Метки",
"detail.device": "Устройство",
@@ -134,13 +134,13 @@
"detail.filter_version": "Показать отчёты для этой версии приложения",
"detail.filter_build": "Показать отчёты этой сборки",
"detail.java_exception": "Исключение Java",
- "detail.native_crash": "Нативный сбой",
+ "detail.native_crash": "Нативный issue",
"detail.find_similar": "Найти похожие отчёты",
"detail.thread": "Поток:",
"detail.signal": "Сигнал:",
- "detail.session_context": "Контекст сессии (сбой)",
+ "detail.session_context": "Контекст сессии (issue)",
"detail.raw_json": "Исходный JSON",
- "login.sign_in_hint": "Войдите для просмотра анонимных отчётов о сбоях",
+ "login.sign_in_hint": "Войдите для просмотра анонимных issue-отчётов",
"login.username": "Имя пользователя",
"login.password": "Пароль",
"login.submit": "Войти",
diff --git a/examples/crash_reporter/backend/public/assets/js/graphs.js b/examples/crash_reporter/backend/public/assets/js/graphs.js
index 1e92f40..6c4b902 100644
--- a/examples/crash_reporter/backend/public/assets/js/graphs.js
+++ b/examples/crash_reporter/backend/public/assets/js/graphs.js
@@ -9,7 +9,7 @@
recv: { type: 'line', field: 'recv_sessions_per_day', title: 'Receive sessions / day', color: COLORS[4] },
bitrate: { type: 'line', field: 'avg_bitrate_kbps_per_day', title: 'Outbound kbps / day', color: COLORS[0] },
'recv-kbps': { type: 'line', field: 'avg_recv_kbps_per_day', title: 'Inbound kbps / day', color: COLORS[1] },
- crashes: { type: 'line', field: 'crashes_per_day', title: 'Crashes / day', color: '#f87171' },
+ crashes: { type: 'line', field: 'crashes_per_day', title: 'Issues / day', color: '#f87171' },
tickets: { type: 'line', field: 'tickets_per_day', title: 'Tickets / day', color: '#c084fc' },
'install-pie': { type: 'pie', field: 'install_sources', title: 'Install sources' },
'ntp-pie': { type: 'pie', field: 'ntp_sources', title: 'NTP sources' },
@@ -17,8 +17,8 @@
versions: { type: 'breakdown', field: 'app_versions', title: 'App versions' },
success: { type: 'kpi', field: 'success_ratio_pct', title: 'Success ratio', suffix: '%' },
'ntp-avg': { type: 'kpi', field: 'ntp_correction_avg_s', title: 'NTP correction', suffix: ' s avg correction' },
- fingerprints: { type: 'toplist', field: 'top_crash_fingerprints', title: 'Top crash fingerprints' },
- 'crash-versions': { type: 'pie', field: 'crashes_by_app_version', title: 'Crashes by app version' },
+ fingerprints: { type: 'toplist', field: 'top_crash_fingerprints', title: 'Top issue fingerprints' },
+ 'crash-versions': { type: 'pie', field: 'crashes_by_app_version', title: 'Issues by app version' },
};
let graphScopes = { user: null, slug: null, platform: null };
diff --git a/examples/crash_reporter/backend/public/assets/js/remote_access.js b/examples/crash_reporter/backend/public/assets/js/remote_access.js
index 45c4a77..84d0ac7 100644
--- a/examples/crash_reporter/backend/public/assets/js/remote_access.js
+++ b/examples/crash_reporter/backend/public/assets/js/remote_access.js
@@ -39,34 +39,166 @@
return d.innerHTML;
}
+ function statusBadge(d) {
+ const label = d.status_label || '';
+ if (label === 'needs_whitelist') {
+ return 'Needs whitelist ';
+ }
+ if (label === 'whitelisted') {
+ return 'Whitelisted ';
+ }
+ if (label === 'stale') {
+ return 'Stale ';
+ }
+ if (label === 'not_opted_in') {
+ return 'Not opted in ';
+ }
+ return 'Polling ';
+ }
+
+ function deviceTitle(d) {
+ const name = (d.device_display || '').trim();
+ if (name) {
+ return name;
+ }
+ const id = String(d.device_id || '');
+ if (id.length > 14) {
+ return id.slice(0, 8) + '…' + id.slice(-4);
+ }
+ return id;
+ }
+
+ function detailLines(d) {
+ const lines = [];
+ lines.push('Device ID: ' + (d.device_id || '—'));
+ if (d.device_display) {
+ lines.push('Name: ' + d.device_display);
+ }
+ if (d.manufacturer || d.model) {
+ lines.push('Hardware: ' + [d.manufacturer, d.model].filter(Boolean).join(' '));
+ }
+ if (d.os_release || d.sdk_int) {
+ lines.push(
+ 'Android: ' +
+ [d.os_release || '', d.sdk_int ? 'SDK ' + d.sdk_int : ''].filter(Boolean).join(' · ')
+ );
+ }
+ lines.push('App version: ' + (d.app_version || '—'));
+ lines.push('Opt-in mode: ' + (d.opt_in_mode || 'none'));
+ lines.push('Last seen: ' + (d.last_seen_at || '—'));
+ if (d.notes) {
+ lines.push('Notes: ' + d.notes);
+ }
+ if (d.issue_count != null) {
+ lines.push('Linked issues: ' + d.issue_count);
+ }
+ if (d.graph_session_count != null) {
+ lines.push('Graph sessions: ' + d.graph_session_count);
+ }
+ return lines;
+ }
+
+ function detailLinks(d) {
+ const links = d.links || [];
+ if (!links.length) {
+ return '';
+ }
+ let html = '';
+ return html;
+ }
+
+ function bindRaTree(root) {
+ root.querySelectorAll('.report-tree-toggle').forEach((btn) => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const id = btn.getAttribute('aria-controls');
+ const briefRow = id ? document.getElementById(id) : null;
+ const row = btn.closest('.report-row');
+ if (!briefRow || !row) return;
+ const open = briefRow.hidden;
+ briefRow.hidden = !open;
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
+ row.classList.toggle('report-row--open', open);
+ });
+ });
+ }
+
function renderDevices(devices) {
const tbody = document.getElementById('ra-devices-tbody');
if (!tbody) return;
- tbody.innerHTML = '';
if (!devices || !devices.length) {
- tbody.innerHTML = 'No devices have polled yet. ';
+ tbody.innerHTML =
+ 'No devices have polled yet. Enable remote access on a device (dev settings) and wait for the next poll. ';
return;
}
- (devices || []).forEach((d) => {
- const tr = document.createElement('tr');
+ let html = '';
+ devices.forEach((d, i) => {
+ const rowKey = 'ra-' + i;
+ const briefId = 'ra-brief-' + rowKey;
const wl = Number(d.whitelisted) === 1;
const canOpen = canOperate() && wl && (d.opt_in_mode === 'wireguard' || d.opt_in_mode === 'rssh');
- const openDisabled = canOpen ? '' : ' disabled title="Whitelist device and wait for WireGuard opt-in poll"';
+ const openDisabled = canOpen ? '' : ' disabled title="Whitelist device and wait for WireGuard/RSSH opt-in poll"';
const wlDisabled = canAdmin() ? '' : ' disabled';
- tr.innerHTML =
- '' + esc(d.device_id) + ' ' +
- '' + esc(d.opt_in_mode || 'none') + ' ' +
- '' + (wl ? 'yes ' : 'no') + ' ' +
- '' + esc(d.last_seen_at || '—') + ' ' +
- '' + esc(d.app_version || '—') + ' ' +
- '' +
- 'Open session ' +
- '' +
- (wl ? 'Revoke WL' : 'Whitelist') + ' ' +
- 'Copy ID ' +
+ const rowClass =
+ 'report-row' + (d.needs_whitelist ? ' ra-device-row--needs-wl' : '');
+ html += ' ';
+ html +=
+ ' ';
+ html +=
+ '' +
+ esc(deviceTitle(d)) +
+ ' ';
+ html += '' + statusBadge(d) + ' ';
+ html += '' + esc(d.opt_in_mode || 'none') + ' ';
+ html += '' + esc(d.last_seen_at || '—') + ' ';
+ html += '' + esc(d.app_version || '—') + ' ';
+ html +=
+ '' +
+ 'Open session ' +
+ '' +
+ (wl ? 'Revoke WL' : 'Whitelist') +
+ ' ' +
+ 'Copy ID ' +
' ';
- tbody.appendChild(tr);
+ html += ' ';
+
+ html +=
+ '';
+ detailLines(d).forEach((line) => {
+ html += '
' + esc(line) + '
';
+ });
+ html += detailLinks(d);
+ html += '
';
});
+ tbody.innerHTML = html;
+ bindRaTree(tbody);
}
function renderSessions(active, inactive) {
@@ -149,6 +281,9 @@
document.addEventListener('click', async (ev) => {
const t = ev.target;
if (!(t instanceof HTMLElement)) return;
+ if (t.closest('.report-tree-toggle, .ra-detail-links a')) {
+ return;
+ }
const copyId = t.getAttribute('data-copy-id');
if (copyId) {
try {
diff --git a/examples/crash_reporter/backend/src/RemoteAccessRepository.php b/examples/crash_reporter/backend/src/RemoteAccessRepository.php
index 224265e..239397e 100644
--- a/examples/crash_reporter/backend/src/RemoteAccessRepository.php
+++ b/examples/crash_reporter/backend/src/RemoteAccessRepository.php
@@ -739,6 +739,176 @@ final class RemoteAccessRepository {
return Database::isMysql() ? date('Y-m-d H:i:s') : gmdate('Y-m-d H:i:s');
}
+ /** @return list> */
+ public static function listDevicesForDashboard(string $issuesBase, string $projectBase): array {
+ $devices = self::listDevices();
+ $out = [];
+ foreach ($devices as $row) {
+ $out[] = self::enrichDeviceRow($row, $issuesBase, $projectBase);
+ }
+ return $out;
+ }
+
+ /** @param array $row */
+ private static function enrichDeviceRow(array $row, string $issuesBase, string $projectBase): array {
+ $deviceId = (string) ($row['device_id'] ?? '');
+ $companyId = (int) ($row['company_id'] ?? 1);
+ $ctx = self::lookupDeviceContext($deviceId, $companyId);
+ $row['needs_whitelist'] = self::deviceNeedsWhitelist($row);
+ $row['status_label'] = self::deviceStatusLabel($row);
+ $row['device_display'] = $ctx['device_display'] ?? '';
+ $row['manufacturer'] = $ctx['manufacturer'] ?? '';
+ $row['model'] = $ctx['model'] ?? '';
+ $row['os_release'] = $ctx['os_release'] ?? '';
+ $row['sdk_int'] = $ctx['sdk_int'] ?? null;
+ $row['issue_count'] = $ctx['issue_count'] ?? 0;
+ $row['graph_session_count'] = $ctx['graph_session_count'] ?? 0;
+ $row['latest_issue_id'] = $ctx['latest_issue_id'] ?? null;
+ $row['links'] = self::deviceLinks($deviceId, $issuesBase, $projectBase, $ctx);
+ if (($row['app_version'] ?? '') === '' && ($ctx['app_version'] ?? '') !== '') {
+ $row['app_version'] = $ctx['app_version'];
+ }
+ return $row;
+ }
+
+ /** @param array $row */
+ private static function deviceNeedsWhitelist(array $row): bool {
+ if ((int) ($row['whitelisted'] ?? 0) === 1) {
+ return false;
+ }
+ $mode = (string) ($row['opt_in_mode'] ?? 'none');
+ if ($mode === self::OPT_NONE) {
+ return false;
+ }
+ return self::isRecentTimestamp($row['last_seen_at'] ?? null, 7 * 86400);
+ }
+
+ /** @param array $row */
+ private static function deviceStatusLabel(array $row): string {
+ if ((int) ($row['whitelisted'] ?? 0) === 1) {
+ return 'whitelisted';
+ }
+ if (self::deviceNeedsWhitelist($row)) {
+ return 'needs_whitelist';
+ }
+ $mode = (string) ($row['opt_in_mode'] ?? 'none');
+ if ($mode === self::OPT_NONE) {
+ return 'not_opted_in';
+ }
+ if (!self::isRecentTimestamp($row['last_seen_at'] ?? null, 7 * 86400)) {
+ return 'stale';
+ }
+ return 'polling';
+ }
+
+ private static function isRecentTimestamp(?string $ts, int $maxAgeSec): bool {
+ if ($ts === null || trim($ts) === '') {
+ return false;
+ }
+ $t = strtotime($ts);
+ if ($t === false) {
+ return false;
+ }
+ return (time() - $t) <= $maxAgeSec;
+ }
+
+ /** @return array */
+ private static function lookupDeviceContext(string $deviceId, int $companyId): array {
+ if ($deviceId === '') {
+ return [];
+ }
+ $pdo = Database::pdo();
+ $like = '%' . $deviceId . '%';
+ $issueCount = 0;
+ $latestIssueId = null;
+ $deviceDisplay = '';
+ $manufacturer = '';
+ $model = '';
+ $osRelease = '';
+ $sdkInt = null;
+ $appVersion = '';
+
+ if (Database::tableExists($pdo, 'reports')) {
+ $cntSt = $pdo->prepare(
+ 'SELECT COUNT(*) FROM reports r WHERE r.company_id = ? AND r.payload_json LIKE ?'
+ );
+ $cntSt->execute([$companyId, $like]);
+ $issueCount = (int) $cntSt->fetchColumn();
+
+ $latestSt = $pdo->prepare(
+ 'SELECT r.id, r.device_model, r.app_version, r.payload_json
+ FROM reports r
+ WHERE r.company_id = ? AND r.payload_json LIKE ?
+ ORDER BY r.received_at_ms DESC LIMIT 1'
+ );
+ $latestSt->execute([$companyId, $like]);
+ $latest = $latestSt->fetch(PDO::FETCH_ASSOC);
+ if (is_array($latest)) {
+ $latestIssueId = (int) ($latest['id'] ?? 0) ?: null;
+ $appVersion = (string) ($latest['app_version'] ?? '');
+ $payload = json_decode((string) ($latest['payload_json'] ?? '{}'), true);
+ if (is_array($payload)) {
+ $device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
+ $manufacturer = trim((string) ($device['manufacturer'] ?? ''));
+ $model = trim((string) ($device['model'] ?? $latest['device_model'] ?? ''));
+ $osRelease = trim((string) ($device['release'] ?? ''));
+ $sdkInt = isset($device['sdk_int']) ? (int) $device['sdk_int'] : null;
+ $deviceDisplay = trim($manufacturer . ' ' . $model);
+ if ($appVersion === '' && is_array($payload['app'] ?? null)) {
+ $appVersion = trim((string) ($payload['app']['version_name'] ?? ''));
+ }
+ }
+ }
+ }
+
+ $graphCount = 0;
+ if (Database::tableExists($pdo, 'graph_sessions')) {
+ $gSt = $pdo->prepare('SELECT COUNT(*) FROM graph_sessions WHERE device_id = ?');
+ $gSt->execute([$deviceId]);
+ $graphCount = (int) $gSt->fetchColumn();
+ }
+
+ return [
+ 'issue_count' => $issueCount,
+ 'latest_issue_id' => $latestIssueId,
+ 'graph_session_count' => $graphCount,
+ 'device_display' => $deviceDisplay,
+ 'manufacturer' => $manufacturer,
+ 'model' => $model,
+ 'os_release' => $osRelease,
+ 'sdk_int' => $sdkInt,
+ 'app_version' => $appVersion,
+ ];
+ }
+
+ /** @param array $ctx @return list */
+ private static function deviceLinks(string $deviceId, string $issuesBase, string $projectBase, array $ctx): array {
+ if ($deviceId === '') {
+ return [];
+ }
+ $links = [];
+ $issueCount = (int) ($ctx['issue_count'] ?? 0);
+ $issuesLabel = $issueCount > 0 ? 'Issues (' . $issueCount . ')' : 'Issues';
+ $links[] = [
+ 'label' => $issuesLabel,
+ 'href' => $issuesBase . '/?view=reports&q=' . rawurlencode($deviceId),
+ ];
+ if (!empty($ctx['latest_issue_id'])) {
+ $links[] = [
+ 'label' => 'Latest issue #' . (int) $ctx['latest_issue_id'],
+ 'href' => $issuesBase . '/?view=report&id=' . (int) $ctx['latest_issue_id'],
+ ];
+ }
+ $graphCount = (int) ($ctx['graph_session_count'] ?? 0);
+ if ($graphCount > 0) {
+ $links[] = [
+ 'label' => 'Graph sessions (' . $graphCount . ')',
+ 'href' => rtrim($projectBase, '/') . '/graphs/',
+ ];
+ }
+ return $links;
+ }
+
private static function uuid4(): string {
$b = random_bytes(16);
$b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php
index 70bb251..c978018 100644
--- a/examples/crash_reporter/backend/views/layout.php
+++ b/examples/crash_reporter/backend/views/layout.php
@@ -86,7 +86,7 @@
Reports
- Browse and triage crash reports.
+ Browse and triage issues.
@@ -111,7 +111,7 @@
Graphs
- Sessions, crashes, and device activity.
+ Sessions, issues, and device activity.
@@ -370,7 +370,7 @@
Success ratio —
Outbound kbps / day
Inbound kbps / day
- Crashes / day
+ Issues / day
@@ -383,7 +383,7 @@
NTP sources
Transport mix
App versions
- Crashes / day
+ Issues / day
Tickets / day
@@ -392,12 +392,12 @@
Platform reliability
Sessions / day (all slugs)
-
Crashes / day
+
Issues / day
Tickets / day
Install sources
NTP correction —
-
Top crash fingerprints
-
Crashes by app version
+
Top issue fingerprints
+
Issues by app version
@@ -438,14 +438,20 @@
Loading…
- Whitelisted devices
+ Devices
+
-
+
- Device ID
+
+ Device
+ Status
Opt-in
- Whitelisted
Last seen
App
Actions
diff --git a/examples/crash_reporter/backend/views/login.php b/examples/crash_reporter/backend/views/login.php
index 9c07d58..39c1f7b 100644
--- a/examples/crash_reporter/backend/views/login.php
+++ b/examples/crash_reporter/backend/views/login.php
@@ -42,7 +42,7 @@ $bp = Auth::basePath();