mirror of
git://f0xx.org/android_cast
synced 2026-07-29 02:18:40 +03:00
Remote access device tree, issue terminology, hide main-pane scrollbar.
Devices dashboard uses an issues-style expandable tree with whitelist cues and links to related issues/graphs; user-facing crash wording becomes issue. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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": "Сборщик",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<p class="muted">Wi‑Fi and USB transport with adaptive bitrate and session metrics.</p>
|
||||
</article>
|
||||
<article class="card card--lift">
|
||||
<h3>Crashes</h3>
|
||||
<h3>Issues</h3>
|
||||
<p class="muted">Ingest, triage tickets, and correlate fingerprints across releases.</p>
|
||||
</article>
|
||||
<article class="card card--lift">
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Войти",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -39,34 +39,166 @@
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function statusBadge(d) {
|
||||
const label = d.status_label || '';
|
||||
if (label === 'needs_whitelist') {
|
||||
return '<span class="tag-pill tag-pill--warn">Needs whitelist</span>';
|
||||
}
|
||||
if (label === 'whitelisted') {
|
||||
return '<span class="tag-pill">Whitelisted</span>';
|
||||
}
|
||||
if (label === 'stale') {
|
||||
return '<span class="muted">Stale</span>';
|
||||
}
|
||||
if (label === 'not_opted_in') {
|
||||
return '<span class="muted">Not opted in</span>';
|
||||
}
|
||||
return '<span class="muted">Polling</span>';
|
||||
}
|
||||
|
||||
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 = '<div class="ra-detail-links">';
|
||||
links.forEach((link) => {
|
||||
html +=
|
||||
'<a class="btn btn-sm" href="' +
|
||||
esc(link.href) +
|
||||
'">' +
|
||||
esc(link.label) +
|
||||
'</a> ';
|
||||
});
|
||||
html += '</div>';
|
||||
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 = '<tr><td colspan="6" class="muted">No devices have polled yet.</td></tr>';
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="7" class="muted">No devices have polled yet. Enable remote access on a device (dev settings) and wait for the next poll.</td></tr>';
|
||||
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 =
|
||||
'<td><code class="ra-device-id">' + esc(d.device_id) + '</code></td>' +
|
||||
'<td>' + esc(d.opt_in_mode || 'none') + '</td>' +
|
||||
'<td>' + (wl ? '<span class="tag-pill">yes</span>' : 'no') + '</td>' +
|
||||
'<td>' + esc(d.last_seen_at || '—') + '</td>' +
|
||||
'<td>' + esc(d.app_version || '—') + '</td>' +
|
||||
'<td>' +
|
||||
'<button type="button" class="btn btn-sm btn-primary"' + openDisabled + ' data-open-session="' + esc(d.device_id) + '">Open session</button> ' +
|
||||
'<button type="button" class="btn btn-sm"' + wlDisabled + ' data-toggle-wl="' + esc(d.device_id) + '" data-wl="' + (wl ? '0' : '1') + '">' +
|
||||
(wl ? 'Revoke WL' : 'Whitelist') + '</button> ' +
|
||||
'<button type="button" class="btn btn-sm" data-copy-id="' + esc(d.device_id) + '">Copy ID</button>' +
|
||||
const rowClass =
|
||||
'report-row' + (d.needs_whitelist ? ' ra-device-row--needs-wl' : '');
|
||||
html += '<tr class="' + rowClass + '">';
|
||||
html +=
|
||||
'<td class="report-tree-cell"><button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="' +
|
||||
briefId +
|
||||
'" title="Show device details"><span class="report-tree-arrow" aria-hidden="true"></span></button></td>';
|
||||
html +=
|
||||
'<td><code class="ra-device-id" title="' +
|
||||
esc(d.device_id) +
|
||||
'">' +
|
||||
esc(deviceTitle(d)) +
|
||||
'</code></td>';
|
||||
html += '<td>' + statusBadge(d) + '</td>';
|
||||
html += '<td>' + esc(d.opt_in_mode || 'none') + '</td>';
|
||||
html += '<td>' + esc(d.last_seen_at || '—') + '</td>';
|
||||
html += '<td>' + esc(d.app_version || '—') + '</td>';
|
||||
html +=
|
||||
'<td class="ra-device-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-primary"' +
|
||||
openDisabled +
|
||||
' data-open-session="' +
|
||||
esc(d.device_id) +
|
||||
'">Open session</button> ' +
|
||||
'<button type="button" class="btn btn-sm"' +
|
||||
wlDisabled +
|
||||
' data-toggle-wl="' +
|
||||
esc(d.device_id) +
|
||||
'" data-wl="' +
|
||||
(wl ? '0' : '1') +
|
||||
'">' +
|
||||
(wl ? 'Revoke WL' : 'Whitelist') +
|
||||
'</button> ' +
|
||||
'<button type="button" class="btn btn-sm" data-copy-id="' +
|
||||
esc(d.device_id) +
|
||||
'">Copy ID</button>' +
|
||||
'</td>';
|
||||
tbody.appendChild(tr);
|
||||
html += '</tr>';
|
||||
|
||||
html +=
|
||||
'<tr class="report-brief-row" id="' +
|
||||
briefId +
|
||||
'" hidden><td colspan="7" class="report-brief-cell"><div class="report-brief-panel"><div class="report-brief">';
|
||||
detailLines(d).forEach((line) => {
|
||||
html += '<p>' + esc(line) + '</p>';
|
||||
});
|
||||
html += detailLinks(d);
|
||||
html += '</div></div></td></tr>';
|
||||
});
|
||||
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 {
|
||||
|
||||
@@ -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<array<string, mixed>> */
|
||||
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> */
|
||||
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<string, mixed> $ctx @return list<array{label: string, href: string}> */
|
||||
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);
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
|
||||
<span class="nav-text">
|
||||
<span class="nav-label" data-i18n="nav.reports">Reports</span>
|
||||
<span class="nav-desc" data-i18n="nav.reports_desc">Browse and triage crash reports.</span>
|
||||
<span class="nav-desc" data-i18n="nav.reports_desc">Browse and triage issues.</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -111,7 +111,7 @@
|
||||
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
|
||||
<span class="nav-text">
|
||||
<span class="nav-label" data-i18n="nav.graphs">Graphs</span>
|
||||
<span class="nav-desc" data-i18n="nav.graphs_desc">Sessions, crashes, and device activity.</span>
|
||||
<span class="nav-desc" data-i18n="nav.graphs_desc">Sessions, issues, and device activity.</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -370,7 +370,7 @@
|
||||
<article class="card card--lift"><h3>Success ratio</h3><p id="graph-user-success" class="graph-kpi">—</p></article>
|
||||
<article class="card card--lift"><h3>Outbound kbps / day</h3><canvas id="graph-user-bitrate" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Inbound kbps / day</h3><canvas id="graph-user-recv-kbps" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Crashes / day</h3><canvas id="graph-user-crashes" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Issues / day</h3><canvas id="graph-user-crashes" width="520" height="160"></canvas></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -383,7 +383,7 @@
|
||||
<article class="card card--lift"><h3>NTP sources</h3><canvas id="graph-slug-ntp-pie" width="520" height="180"></canvas></article>
|
||||
<article class="card card--lift"><h3>Transport mix</h3><div id="graph-slug-transport" class="graph-breakdown"></div></article>
|
||||
<article class="card card--lift"><h3>App versions</h3><div id="graph-slug-versions" class="graph-breakdown"></div></article>
|
||||
<article class="card card--lift"><h3>Crashes / day</h3><canvas id="graph-slug-crashes" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Issues / day</h3><canvas id="graph-slug-crashes" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Tickets / day</h3><canvas id="graph-slug-tickets" width="520" height="160"></canvas></article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -392,12 +392,12 @@
|
||||
<h2 class="graphs-scope-title">Platform reliability</h2>
|
||||
<div class="cards cards--lift graphs-grid">
|
||||
<article class="card card--lift"><h3>Sessions / day (all slugs)</h3><canvas id="graph-platform-sessions" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Crashes / day</h3><canvas id="graph-platform-crashes" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Issues / day</h3><canvas id="graph-platform-crashes" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Tickets / day</h3><canvas id="graph-platform-tickets" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Install sources</h3><canvas id="graph-platform-install-pie" width="520" height="180"></canvas></article>
|
||||
<article class="card card--lift"><h3>NTP correction</h3><p id="graph-platform-ntp-avg" class="graph-kpi muted">—</p></article>
|
||||
<article class="card card--lift"><h3>Top crash fingerprints</h3><div id="graph-platform-fingerprints" class="graph-breakdown"></div></article>
|
||||
<article class="card card--lift"><h3>Crashes by app version</h3><canvas id="graph-platform-crash-versions" width="520" height="180"></canvas></article>
|
||||
<article class="card card--lift"><h3>Top issue fingerprints</h3><div id="graph-platform-fingerprints" class="graph-breakdown"></div></article>
|
||||
<article class="card card--lift"><h3>Issues by app version</h3><canvas id="graph-platform-crash-versions" width="520" height="180"></canvas></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -438,14 +438,20 @@
|
||||
<p id="ra-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||
|
||||
<section class="graphs-scope">
|
||||
<h2 class="graphs-scope-title">Whitelisted devices</h2>
|
||||
<h2 class="graphs-scope-title">Devices</h2>
|
||||
<p class="muted graphs-footnote ra-whitelist-hint">
|
||||
Devices appear after they poll remote access (dev settings → WireGuard or RSSH). Sort is by <strong>last seen</strong> — top rows are actively polling.
|
||||
Whitelist rows marked <span class="tag-pill tag-pill--warn">Needs whitelist</span> (opted in, not yet allowed).
|
||||
Stale rows have not polled in 7+ days.
|
||||
</p>
|
||||
<div class="reports-table-wrap">
|
||||
<table class="data-table reports-table--cols" id="ra-devices-table">
|
||||
<table class="data-table reports-tree reports-table--cols" id="ra-devices-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Device ID</th>
|
||||
<th class="report-tree-head" aria-label="Expand"></th>
|
||||
<th scope="col">Device</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Opt-in</th>
|
||||
<th scope="col">Whitelisted</th>
|
||||
<th scope="col">Last seen</th>
|
||||
<th scope="col">App</th>
|
||||
<th scope="col">Actions</th>
|
||||
|
||||
@@ -42,7 +42,7 @@ $bp = Auth::basePath();
|
||||
</div>
|
||||
<form class="login-card" method="post" action="<?= h($bp) ?>/login">
|
||||
<h1><?= h(cfg('app_name')) ?></h1>
|
||||
<p class="muted" data-i18n="login.sign_in_hint">Sign in to view anonymous crash reports</p>
|
||||
<p class="muted" data-i18n="login.sign_in_hint">Sign in to view anonymous issue reports</p>
|
||||
<?php if (!empty($loginError)): ?>
|
||||
<div class="alert" data-i18n="login.error"><?= h($loginError) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -16,13 +16,13 @@ $proj = '/app/androidcast_project';
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="console-home-intro" data-i18n="home.intro">Crash triage, tickets, and session metrics — pick a workspace below.</p>
|
||||
<p class="console-home-intro" data-i18n="home.intro">Issue triage, tickets, and session metrics — pick a workspace below.</p>
|
||||
|
||||
<div class="cards cards--lift console-home-cards">
|
||||
<a href="<?= h($bp) ?>/?view=reports" class="card card--lift console-home-card">
|
||||
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
|
||||
<h2 data-i18n="home.card_issues">Issues</h2>
|
||||
<p class="muted" data-i18n="home.card_issues_desc">Browse and triage crash reports, tags, and fingerprints.</p>
|
||||
<p class="muted" data-i18n="home.card_issues_desc">Browse and triage issues, tags, and fingerprints.</p>
|
||||
</a>
|
||||
<button type="button" class="card card--lift console-home-card console-home-card--btn js-new-issue-btn">
|
||||
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
|
||||
@@ -42,7 +42,7 @@ $proj = '/app/androidcast_project';
|
||||
<a href="<?= h($proj) ?>/graphs/" class="card card--lift console-home-card">
|
||||
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
|
||||
<h2 data-i18n="home.card_graphs">Graphs</h2>
|
||||
<p class="muted" data-i18n="home.card_graphs_desc">Sessions, crashes, and device activity over time.</p>
|
||||
<p class="muted" data-i18n="home.card_graphs_desc">Sessions, issues, and device activity over time.</p>
|
||||
</a>
|
||||
<?php if (Rbac::can('short_links_view')): ?>
|
||||
<a href="<?= h($bp) ?>/?view=short_links" class="card card--lift console-home-card">
|
||||
|
||||
@@ -38,7 +38,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
|
||||
<span class="back-icon" aria-hidden="true"></span>
|
||||
</a>
|
||||
<div>
|
||||
<h1 data-i18n="detail.title">Crash report</h1>
|
||||
<h1 data-i18n="detail.title">Issue report</h1>
|
||||
<p class="muted" data-i18n="detail.report_meta" data-i18n-id="<?= h($report['report_id'] ?? '') ?>" data-i18n-type="<?= h($p['crash_type'] ?? '') ?>">Report <?= h($report['report_id'] ?? '') ?> · <?= h($p['crash_type'] ?? '') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
|
||||
<?php if (!empty($p['native'])): $n = $p['native']; ?>
|
||||
<section class="stack-block">
|
||||
<div class="stack-block-head">
|
||||
<h2>Native crash</h2>
|
||||
<h2 data-i18n="detail.native_crash">Native issue</h2>
|
||||
<a class="btn btn-similar" href="<?= h($similarUrl) ?>">Find similar reports</a>
|
||||
</div>
|
||||
<p class="exc">Signal: <?= h($n['signal'] ?? '') ?></p>
|
||||
@@ -162,7 +162,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
|
||||
<span class="report-tree-arrow" aria-hidden="true"></span>
|
||||
</button>
|
||||
</span>
|
||||
<span class="detail-tree-caption">Session context (crash)</span>
|
||||
<span class="detail-tree-caption" data-i18n="detail.session_context">Session context (issue)</span>
|
||||
</div>
|
||||
<div class="detail-tree-body" id="detail-session" hidden>
|
||||
<pre class="stack"><?= h($sessionJson) ?></pre>
|
||||
|
||||
Reference in New Issue
Block a user