1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 07:39:15 +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:
Anton Afanasyeu
2026-06-12 21:53:40 +02:00
parent 7bb8ad097f
commit f56301fbf7
14 changed files with 417 additions and 63 deletions

View File

@@ -17,11 +17,11 @@
"nav.remote": "Remote access", "nav.remote": "Remote access",
"nav.short_links": "Short links", "nav.short_links": "Short links",
"nav.short_links_desc": "Mint bearer tokens and shorten URLs for s.f0xx.org.", "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.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.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.remote_desc": "WireGuard sessions and device reachability.",
"nav.builder_desc": "Docker CI for APK baking and OTA artifacts.", "nav.builder_desc": "Docker CI for APK baking and OTA artifacts.",
"nav.builder": "Builder", "nav.builder": "Builder",

View File

@@ -19,9 +19,9 @@
"nav.short_links_desc": "Токены и сокращение URL для s.f0xx.org.", "nav.short_links_desc": "Токены и сокращение URL для s.f0xx.org.",
"nav.console_desc": "Главная консоли и карточки разделов.", "nav.console_desc": "Главная консоли и карточки разделов.",
"nav.git_desc": "Репозитории, ветки и теги.", "nav.git_desc": "Репозитории, ветки и теги.",
"nav.issues_desc": "Просмотр и разбор отчётов о сбоях.", "nav.issues_desc": "Просмотр и разбор issues.",
"nav.tickets_desc": "Задачи roadmap, QA и теги.", "nav.tickets_desc": "Задачи roadmap, QA и теги.",
"nav.graphs_desc": "Сессии, сбои и активность устройств.", "nav.graphs_desc": "Сессии, issues и активность устройств.",
"nav.remote_desc": "Сессии WireGuard и доступ к устройствам.", "nav.remote_desc": "Сессии WireGuard и доступ к устройствам.",
"nav.builder_desc": "Docker CI для сборки APK и OTA.", "nav.builder_desc": "Docker CI для сборки APK и OTA.",
"nav.builder": "Сборщик", "nav.builder": "Сборщик",

View File

@@ -17,7 +17,7 @@
<p class="muted">WiFi and USB transport with adaptive bitrate and session metrics.</p> <p class="muted">WiFi and USB transport with adaptive bitrate and session metrics.</p>
</article> </article>
<article class="card card--lift"> <article class="card card--lift">
<h3>Crashes</h3> <h3>Issues</h3>
<p class="muted">Ingest, triage tickets, and correlate fingerprints across releases.</p> <p class="muted">Ingest, triage tickets, and correlate fingerprints across releases.</p>
</article> </article>
<article class="card card--lift"> <article class="card card--lift">

View File

@@ -31,7 +31,10 @@ if ($method === 'GET') {
$inactive = array_values(array_filter($sessions, static fn($s) => !in_array($s['status'] ?? '', ['pending', 'active'], true))); $inactive = array_values(array_filter($sessions, static fn($s) => !in_array($s['status'] ?? '', ['pending', 'active'], true)));
json_out([ json_out([
'ok' => true, 'ok' => true,
'devices' => RemoteAccessRepository::listDevices(), 'devices' => RemoteAccessRepository::listDevicesForDashboard(
rtrim(Auth::basePath(), '/'),
'/app/androidcast_project'
),
'active_sessions' => $active, 'active_sessions' => $active,
'inactive_sessions' => array_slice($inactive, 0, 50), 'inactive_sessions' => array_slice($inactive, 0, 50),
'recent_events' => RemoteAccessRepository::listEvents(30), 'recent_events' => RemoteAccessRepository::listEvents(30),

View File

@@ -547,6 +547,13 @@ a:hover { text-decoration: underline; }
padding: 24px 28px; padding: 24px 28px;
box-sizing: border-box; box-sizing: border-box;
-webkit-overflow-scrolling: touch; -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; } body { --footer-h: 40px; }
.bottom-bar { .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 { color: var(--accent, #3d8bfd); text-decoration: none; }
.graph-breakdown-row a:hover { text-decoration: underline; } .graph-breakdown-row a:hover { text-decoration: underline; }
.graphs-footnote { margin-top: 20px; font-size: 0.88rem; } .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;
}

View File

@@ -6,10 +6,10 @@
"nav.logout": "Logout", "nav.logout": "Logout",
"nav.toggle": "Navigation", "nav.toggle": "Navigation",
"nav.home_desc": "Console home and workspace cards.", "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.tickets_desc": "Roadmap tasks, QA items, and tags.",
"nav.graphs": "Graphs", "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": "Remote access",
"nav.remote_desc": "WireGuard sessions and device reachability.", "nav.remote_desc": "WireGuard sessions and device reachability.",
"nav.access": "Access", "nav.access": "Access",
@@ -23,14 +23,14 @@
"lang.en": "English", "lang.en": "English",
"lang.ru": "Russian", "lang.ru": "Russian",
"home.title": "Console", "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": "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_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_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_tickets_desc": "Roadmap tasks, QA items, and workflow tags.",
"home.card_graphs": "Graphs", "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": "Short links",
"home.card_short_links_desc": "Mint bearer tokens and shorten URLs for s.f0xx.org.", "home.card_short_links_desc": "Mint bearer tokens and shorten URLs for s.f0xx.org.",
"home.more": "All services", "home.more": "All services",
@@ -109,7 +109,7 @@
"tag.filter_and": "All tags (AND)", "tag.filter_and": "All tags (AND)",
"tag.filter_or": "Any tag (OR)", "tag.filter_or": "Any tag (OR)",
"detail.back": "Back to list", "detail.back": "Back to list",
"detail.title": "Crash report", "detail.title": "Issue report",
"detail.report_meta": "Report {id} · {type}", "detail.report_meta": "Report {id} · {type}",
"detail.tags": "Tags", "detail.tags": "Tags",
"detail.device": "Device", "detail.device": "Device",
@@ -125,13 +125,13 @@
"detail.filter_version": "Show reports for this app version", "detail.filter_version": "Show reports for this app version",
"detail.filter_build": "Show reports from this build", "detail.filter_build": "Show reports from this build",
"detail.java_exception": "Java exception", "detail.java_exception": "Java exception",
"detail.native_crash": "Native crash", "detail.native_crash": "Native issue",
"detail.find_similar": "Find similar reports", "detail.find_similar": "Find similar issues",
"detail.thread": "Thread:", "detail.thread": "Thread:",
"detail.signal": "Signal:", "detail.signal": "Signal:",
"detail.session_context": "Session context (crash)", "detail.session_context": "Session context (issue)",
"detail.raw_json": "Raw JSON", "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.username": "Username",
"login.password": "Password", "login.password": "Password",
"login.submit": "Sign in", "login.submit": "Sign in",

View File

@@ -16,10 +16,10 @@
"nav.logout": "Выход", "nav.logout": "Выход",
"nav.toggle": "Навигация", "nav.toggle": "Навигация",
"nav.home_desc": "Главная консоли и карточки разделов.", "nav.home_desc": "Главная консоли и карточки разделов.",
"nav.reports_desc": "Просмотр и разбор отчётов о сбоях.", "nav.reports_desc": "Просмотр и разбор issues.",
"nav.tickets_desc": "Задачи roadmap, QA и теги.", "nav.tickets_desc": "Задачи roadmap, QA и теги.",
"nav.graphs": "Графики", "nav.graphs": "Графики",
"nav.graphs_desc": "Сессии, сбои и активность устройств.", "nav.graphs_desc": "Сессии, issues и активность устройств.",
"nav.remote": "Удалённый доступ", "nav.remote": "Удалённый доступ",
"nav.remote_desc": "Сессии WireGuard и доступ к устройствам.", "nav.remote_desc": "Сессии WireGuard и доступ к устройствам.",
"nav.access": "Доступ", "nav.access": "Доступ",
@@ -35,12 +35,12 @@
"home.title": "Консоль", "home.title": "Консоль",
"home.intro": "Сбои, задачи и метрики сессий — выберите раздел ниже.", "home.intro": "Сбои, задачи и метрики сессий — выберите раздел ниже.",
"home.card_issues": "Проблемы", "home.card_issues": "Проблемы",
"home.card_issues_desc": "Просмотр и разбор отчётов о сбоях, тегов и отпечатков.", "home.card_issues_desc": "Просмотр и разбор issues, тегов и отпечатков.",
"home.card_new_issue_desc": "Создать новую проблему или задачу.", "home.card_new_issue_desc": "Создать новую проблему или задачу.",
"home.card_new_ticket_desc": "Создать тикет roadmap или QA за один шаг.", "home.card_new_ticket_desc": "Создать тикет roadmap или QA за один шаг.",
"home.card_tickets_desc": "Задачи, QA и теги workflow.", "home.card_tickets_desc": "Задачи, QA и теги workflow.",
"home.card_graphs": "Графики", "home.card_graphs": "Графики",
"home.card_graphs_desc": "Сессии, сбои и активность устройств.", "home.card_graphs_desc": "Сессии, issues и активность устройств.",
"home.card_short_links": "Короткие ссылки", "home.card_short_links": "Короткие ссылки",
"home.card_short_links_desc": "Токены и сокращение URL для s.f0xx.org.", "home.card_short_links_desc": "Токены и сокращение URL для s.f0xx.org.",
"home.more": "Все сервисы", "home.more": "Все сервисы",
@@ -118,7 +118,7 @@
"tag.filter_and": "Все метки (И)", "tag.filter_and": "Все метки (И)",
"tag.filter_or": "Любая метка (ИЛИ)", "tag.filter_or": "Любая метка (ИЛИ)",
"detail.back": "К списку", "detail.back": "К списку",
"detail.title": "Отчёт о сбое", "detail.title": "Issue-отчёт",
"detail.report_meta": "Отчёт {id} · {type}", "detail.report_meta": "Отчёт {id} · {type}",
"detail.tags": "Метки", "detail.tags": "Метки",
"detail.device": "Устройство", "detail.device": "Устройство",
@@ -134,13 +134,13 @@
"detail.filter_version": "Показать отчёты для этой версии приложения", "detail.filter_version": "Показать отчёты для этой версии приложения",
"detail.filter_build": "Показать отчёты этой сборки", "detail.filter_build": "Показать отчёты этой сборки",
"detail.java_exception": "Исключение Java", "detail.java_exception": "Исключение Java",
"detail.native_crash": "Нативный сбой", "detail.native_crash": "Нативный issue",
"detail.find_similar": "Найти похожие отчёты", "detail.find_similar": "Найти похожие отчёты",
"detail.thread": "Поток:", "detail.thread": "Поток:",
"detail.signal": "Сигнал:", "detail.signal": "Сигнал:",
"detail.session_context": "Контекст сессии (сбой)", "detail.session_context": "Контекст сессии (issue)",
"detail.raw_json": "Исходный JSON", "detail.raw_json": "Исходный JSON",
"login.sign_in_hint": "Войдите для просмотра анонимных отчётов о сбоях", "login.sign_in_hint": "Войдите для просмотра анонимных issue-отчётов",
"login.username": "Имя пользователя", "login.username": "Имя пользователя",
"login.password": "Пароль", "login.password": "Пароль",
"login.submit": "Войти", "login.submit": "Войти",

View File

@@ -9,7 +9,7 @@
recv: { type: 'line', field: 'recv_sessions_per_day', title: 'Receive sessions / day', color: COLORS[4] }, 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] }, 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] }, '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' }, tickets: { type: 'line', field: 'tickets_per_day', title: 'Tickets / day', color: '#c084fc' },
'install-pie': { type: 'pie', field: 'install_sources', title: 'Install sources' }, 'install-pie': { type: 'pie', field: 'install_sources', title: 'Install sources' },
'ntp-pie': { type: 'pie', field: 'ntp_sources', title: 'NTP sources' }, 'ntp-pie': { type: 'pie', field: 'ntp_sources', title: 'NTP sources' },
@@ -17,8 +17,8 @@
versions: { type: 'breakdown', field: 'app_versions', title: 'App versions' }, versions: { type: 'breakdown', field: 'app_versions', title: 'App versions' },
success: { type: 'kpi', field: 'success_ratio_pct', title: 'Success ratio', suffix: '%' }, 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' }, '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' }, fingerprints: { type: 'toplist', field: 'top_crash_fingerprints', title: 'Top issue fingerprints' },
'crash-versions': { type: 'pie', field: 'crashes_by_app_version', title: 'Crashes by app version' }, 'crash-versions': { type: 'pie', field: 'crashes_by_app_version', title: 'Issues by app version' },
}; };
let graphScopes = { user: null, slug: null, platform: null }; let graphScopes = { user: null, slug: null, platform: null };

View File

@@ -39,34 +39,166 @@
return d.innerHTML; 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) { function renderDevices(devices) {
const tbody = document.getElementById('ra-devices-tbody'); const tbody = document.getElementById('ra-devices-tbody');
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = '';
if (!devices || !devices.length) { 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; return;
} }
(devices || []).forEach((d) => { let html = '';
const tr = document.createElement('tr'); devices.forEach((d, i) => {
const rowKey = 'ra-' + i;
const briefId = 'ra-brief-' + rowKey;
const wl = Number(d.whitelisted) === 1; const wl = Number(d.whitelisted) === 1;
const canOpen = canOperate() && wl && (d.opt_in_mode === 'wireguard' || d.opt_in_mode === 'rssh'); 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'; const wlDisabled = canAdmin() ? '' : ' disabled';
tr.innerHTML = const rowClass =
'<td><code class="ra-device-id">' + esc(d.device_id) + '</code></td>' + 'report-row' + (d.needs_whitelist ? ' ra-device-row--needs-wl' : '');
'<td>' + esc(d.opt_in_mode || 'none') + '</td>' + html += '<tr class="' + rowClass + '">';
'<td>' + (wl ? '<span class="tag-pill">yes</span>' : 'no') + '</td>' + html +=
'<td>' + esc(d.last_seen_at || '—') + '</td>' + '<td class="report-tree-cell"><button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="' +
'<td>' + esc(d.app_version || '—') + '</td>' + briefId +
'<td>' + '" title="Show device details"><span class="report-tree-arrow" aria-hidden="true"></span></button></td>';
'<button type="button" class="btn btn-sm btn-primary"' + openDisabled + ' data-open-session="' + esc(d.device_id) + '">Open session</button> ' + html +=
'<button type="button" class="btn btn-sm"' + wlDisabled + ' data-toggle-wl="' + esc(d.device_id) + '" data-wl="' + (wl ? '0' : '1') + '">' + '<td><code class="ra-device-id" title="' +
(wl ? 'Revoke WL' : 'Whitelist') + '</button> ' + esc(d.device_id) +
'<button type="button" class="btn btn-sm" data-copy-id="' + esc(d.device_id) + '">Copy ID</button>' + '">' +
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>'; '</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) { function renderSessions(active, inactive) {
@@ -149,6 +281,9 @@
document.addEventListener('click', async (ev) => { document.addEventListener('click', async (ev) => {
const t = ev.target; const t = ev.target;
if (!(t instanceof HTMLElement)) return; if (!(t instanceof HTMLElement)) return;
if (t.closest('.report-tree-toggle, .ra-detail-links a')) {
return;
}
const copyId = t.getAttribute('data-copy-id'); const copyId = t.getAttribute('data-copy-id');
if (copyId) { if (copyId) {
try { try {

View File

@@ -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 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 { private static function uuid4(): string {
$b = random_bytes(16); $b = random_bytes(16);
$b[6] = chr((ord($b[6]) & 0x0f) | 0x40); $b[6] = chr((ord($b[6]) & 0x0f) | 0x40);

View File

@@ -86,7 +86,7 @@
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span> <span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
<span class="nav-text"> <span class="nav-text">
<span class="nav-label" data-i18n="nav.reports">Reports</span> <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> </span>
</a> </a>
</li> </li>
@@ -111,7 +111,7 @@
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span> <span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
<span class="nav-text"> <span class="nav-text">
<span class="nav-label" data-i18n="nav.graphs">Graphs</span> <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> </span>
</a> </a>
</li> </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>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>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>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> </div>
</section> </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>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>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>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> <article class="card card--lift"><h3>Tickets / day</h3><canvas id="graph-slug-tickets" width="520" height="160"></canvas></article>
</div> </div>
</section> </section>
@@ -392,12 +392,12 @@
<h2 class="graphs-scope-title">Platform reliability</h2> <h2 class="graphs-scope-title">Platform reliability</h2>
<div class="cards cards--lift graphs-grid"> <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>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>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>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>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>Top issue 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>Issues by app version</h3><canvas id="graph-platform-crash-versions" width="520" height="180"></canvas></article>
</div> </div>
</section> </section>
@@ -438,14 +438,20 @@
<p id="ra-status" class="reports-status muted" aria-live="polite">Loading…</p> <p id="ra-status" class="reports-status muted" aria-live="polite">Loading…</p>
<section class="graphs-scope"> <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"> <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> <thead>
<tr> <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">Opt-in</th>
<th scope="col">Whitelisted</th>
<th scope="col">Last seen</th> <th scope="col">Last seen</th>
<th scope="col">App</th> <th scope="col">App</th>
<th scope="col">Actions</th> <th scope="col">Actions</th>

View File

@@ -42,7 +42,7 @@ $bp = Auth::basePath();
</div> </div>
<form class="login-card" method="post" action="<?= h($bp) ?>/login"> <form class="login-card" method="post" action="<?= h($bp) ?>/login">
<h1><?= h(cfg('app_name')) ?></h1> <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)): ?> <?php if (!empty($loginError)): ?>
<div class="alert" data-i18n="login.error"><?= h($loginError) ?></div> <div class="alert" data-i18n="login.error"><?= h($loginError) ?></div>
<?php endif; ?> <?php endif; ?>

View File

@@ -16,13 +16,13 @@ $proj = '/app/androidcast_project';
</label> </label>
</div> </div>
</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"> <div class="cards cards--lift console-home-cards">
<a href="<?= h($bp) ?>/?view=reports" class="card card--lift console-home-card"> <a href="<?= h($bp) ?>/?view=reports" class="card card--lift console-home-card">
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span> <span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
<h2 data-i18n="home.card_issues">Issues</h2> <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> </a>
<button type="button" class="card card--lift console-home-card console-home-card--btn js-new-issue-btn"> <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> <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"> <a href="<?= h($proj) ?>/graphs/" class="card card--lift console-home-card">
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span> <span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
<h2 data-i18n="home.card_graphs">Graphs</h2> <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> </a>
<?php if (Rbac::can('short_links_view')): ?> <?php if (Rbac::can('short_links_view')): ?>
<a href="<?= h($bp) ?>/?view=short_links" class="card card--lift console-home-card"> <a href="<?= h($bp) ?>/?view=short_links" class="card card--lift console-home-card">

View File

@@ -38,7 +38,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
<span class="back-icon" aria-hidden="true"></span> <span class="back-icon" aria-hidden="true"></span>
</a> </a>
<div> <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> <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>
</div> </div>
@@ -145,7 +145,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
<?php if (!empty($p['native'])): $n = $p['native']; ?> <?php if (!empty($p['native'])): $n = $p['native']; ?>
<section class="stack-block"> <section class="stack-block">
<div class="stack-block-head"> <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> <a class="btn btn-similar" href="<?= h($similarUrl) ?>">Find similar reports</a>
</div> </div>
<p class="exc">Signal: <?= h($n['signal'] ?? '') ?></p> <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> <span class="report-tree-arrow" aria-hidden="true"></span>
</button> </button>
</span> </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>
<div class="detail-tree-body" id="detail-session" hidden> <div class="detail-tree-body" id="detail-session" hidden>
<pre class="stack"><?= h($sessionJson) ?></pre> <pre class="stack"><?= h($sessionJson) ?></pre>