This commit is contained in:
Anton Afanasyeu
2026-06-23 12:29:36 +02:00
commit 4500fc5e0a
8 changed files with 1760 additions and 0 deletions

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# ac-be-remote-access
Thin BE UI. Remote: `git://f0xx.org/ac/ac-be-remote-access`

19
composer.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "androidcast/be-remote-access",
"description": "Remote access admin UI",
"require": {
"php": ">=8.1",
"androidcast/platform-php": "dev-next",
"androidcast/platform-web": "dev-next"
},
"repositories": [
{
"type": "vcs",
"url": "git://f0xx.org/ac/ac-platform-php"
},
{
"type": "vcs",
"url": "git://f0xx.org/ac/ac-platform-web"
}
]
}

View File

@@ -0,0 +1,801 @@
(function () {
const STORAGE = {
colWidths: 'ra_console_col_widths',
colOrder: 'ra_console_col_order',
};
const RA_COLUMNS = [
{ key: 'device_name', label: 'Device' },
{ key: 'status', label: 'Status' },
{ key: 'opt_in', label: 'Opt-in' },
{ key: 'android_api', label: 'Android API' },
{ key: 'android_version', label: 'Android version' },
{ key: 'last_seen', label: 'Last seen' },
{ key: 'app_version', label: 'App' },
];
const DEFAULT_COL_WIDTHS = {
expand: 40,
device_name: 160,
status: 120,
opt_in: 88,
android_api: 96,
android_version: 112,
last_seen: 152,
app_version: 80,
actions: 220,
};
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function canOperate() {
return document.body.getAttribute('data-can-ra-operate') === '1';
}
function canAdmin() {
return document.body.getAttribute('data-can-ra-admin') === '1';
}
function lsGet(key, fallback) {
try {
const v = localStorage.getItem(key);
return v === null ? fallback : v;
} catch {
return fallback;
}
}
function lsSet(key, value) {
try {
localStorage.setItem(key, value);
} catch { /* ignore */ }
}
function apiUrl(action, params) {
const q = new URLSearchParams(params || {});
q.set('action', action);
return basePath() + '/api/remote_access.php?' + q.toString();
}
function setStatus(msg, isError) {
const el = document.getElementById('ra-status');
if (!el) return;
el.textContent = msg;
el.classList.toggle('error', !!isError);
}
async function fetchJson(url, opts) {
const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {}));
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data.error || ('HTTP ' + res.status));
}
return data;
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function formatBytes(n) {
const b = Number(n) || 0;
if (b < 1024) return b + ' B';
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KiB';
if (b < 1024 * 1024 * 1024) return (b / (1024 * 1024)).toFixed(2) + ' MiB';
return (b / (1024 * 1024 * 1024)).toFixed(2) + ' GiB';
}
function getColWidths() {
try {
const w = JSON.parse(lsGet(STORAGE.colWidths, '{}'));
return { ...DEFAULT_COL_WIDTHS, ...(w && typeof w === 'object' ? w : {}) };
} catch {
return { ...DEFAULT_COL_WIDTHS };
}
}
function saveColWidths(widths) {
lsSet(STORAGE.colWidths, JSON.stringify(widths));
}
function getOrderedColumnKeys() {
const defaults = RA_COLUMNS.map((c) => c.key);
try {
const saved = JSON.parse(lsGet(STORAGE.colOrder, '[]'));
if (Array.isArray(saved) && saved.length) {
const out = saved.filter((k) => defaults.includes(k));
defaults.forEach((k) => {
if (!out.includes(k)) out.push(k);
});
return out;
}
} catch { /* ignore */ }
return defaults;
}
function saveColumnOrder(keys) {
lsSet(STORAGE.colOrder, JSON.stringify(keys));
}
function getDisplayColumns() {
return getOrderedColumnKeys()
.map((k) => RA_COLUMNS.find((c) => c.key === k))
.filter(Boolean);
}
function columnLabelByKey(key) {
const c = RA_COLUMNS.find((col) => col.key === key);
if (c) return c.label;
if (key === 'actions') return 'Actions';
return key;
}
function colspan() {
return getDisplayColumns().length + 2;
}
function applyColgroup() {
const cg = document.getElementById('ra-devices-colgroup');
if (!cg) return;
const widths = getColWidths();
const order = getOrderedColumnKeys();
let html =
'<col class="col-expand" style="width:' + (widths.expand || 40) + 'px" />';
order.forEach((k) => {
html +=
'<col data-col="' +
esc(k) +
'" style="width:' +
(widths[k] || 100) +
'px" />';
});
html +=
'<col class="col-actions" style="width:' + (widths.actions || 220) + 'px" />';
cg.innerHTML = html;
}
function clearColumnDropMarkers(thead) {
thead.querySelectorAll('.th-drop-target').forEach((el) => {
el.classList.remove('th-drop-target');
});
thead.querySelectorAll('.th-dragging').forEach((el) => {
el.classList.remove('th-dragging');
});
}
function renderHead() {
const thead = document.getElementById('ra-devices-thead');
if (!thead) return;
const cols = getDisplayColumns();
const widths = getColWidths();
let html =
'<tr><th class="report-tree-head" aria-label="Expand"><span class="col-resizer" data-resize-col="expand"></span></th>';
cols.forEach((c) => {
const w = widths[c.key] ? ' style="width:' + widths[c.key] + 'px"' : '';
html +=
'<th class="th-draggable"' +
' draggable="true" data-col-key="' +
esc(c.key) +
'" title="Drag to reorder"' +
w +
' scope="col"><span class="th-inner">' +
'<span class="th-drag" aria-hidden="true" title="Drag column">⋮⋮</span>' +
'<span class="th-label">' +
esc(c.label) +
'</span></span>' +
'<span class="col-resizer" data-resize-col="' +
esc(c.key) +
'"></span></th>';
});
html +=
'<th class="col-actions" scope="col" style="width:' +
(widths.actions || 220) +
'px">Actions<span class="col-resizer" data-resize-col="actions"></span></th></tr>';
thead.innerHTML = html;
}
function bindTableLayout() {
const table = document.getElementById('ra-devices-table');
const thead = document.getElementById('ra-devices-thead');
if (!table || !thead || table.dataset.layoutBound === '1') return;
table.dataset.layoutBound = '1';
thead.addEventListener('dragstart', (e) => {
if (e.target.closest('.col-resizer')) {
e.preventDefault();
return;
}
const th = e.target.closest('th.th-draggable[data-col-key]');
if (!th) return;
const sourceKey = th.getAttribute('data-col-key');
if (!sourceKey) return;
table.dataset.dragColKey = sourceKey;
th.classList.add('th-dragging');
const ghost = document.createElement('div');
ghost.className = 'col-drag-ghost';
ghost.textContent = columnLabelByKey(sourceKey);
ghost.setAttribute('aria-hidden', 'true');
document.body.appendChild(ghost);
table._dragGhost = ghost;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', sourceKey);
try {
e.dataTransfer.setDragImage(ghost, 16, 14);
} catch { /* ignore */ }
}
});
thead.addEventListener('dragend', () => {
clearColumnDropMarkers(thead);
delete table.dataset.dragColKey;
if (table._dragGhost) {
table._dragGhost.remove();
table._dragGhost = null;
}
});
thead.addEventListener('dragenter', (e) => {
if (!table.dataset.dragColKey) return;
e.preventDefault();
});
thead.addEventListener('dragover', (e) => {
if (!table.dataset.dragColKey) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const th = e.target.closest('th[data-col-key]');
if (!th) return;
const active = thead.querySelector('.th-drop-target');
if (active && active !== th) active.classList.remove('th-drop-target');
th.classList.add('th-drop-target');
});
thead.addEventListener('dragleave', (e) => {
const th = e.target.closest('th[data-col-key]');
if (!th) return;
const rel = e.relatedTarget;
if (rel && th.contains(rel)) return;
th.classList.remove('th-drop-target');
});
thead.addEventListener('drop', (e) => {
const th = e.target.closest('th[data-col-key]');
if (!th) return;
e.preventDefault();
e.stopPropagation();
const sourceKey =
table.dataset.dragColKey ||
(e.dataTransfer && e.dataTransfer.getData('text/plain')) ||
'';
const targetKey = th.getAttribute('data-col-key');
clearColumnDropMarkers(thead);
delete table.dataset.dragColKey;
if (table._dragGhost) {
table._dragGhost.remove();
table._dragGhost = null;
}
if (!sourceKey || !targetKey || sourceKey === targetKey) return;
const order = getOrderedColumnKeys();
const from = order.indexOf(sourceKey);
const to = order.indexOf(targetKey);
if (from < 0 || to < 0) return;
order.splice(from, 1);
order.splice(to, 0, sourceKey);
saveColumnOrder(order);
refreshTableChrome(lastDevices, lastExpanded);
});
table.addEventListener('mousedown', (e) => {
const handle = e.target.closest('.col-resizer');
if (!handle) return;
e.preventDefault();
e.stopPropagation();
const colKey = handle.getAttribute('data-resize-col');
const th = handle.closest('th');
if (!colKey || !th) return;
const widths = getColWidths();
const startX = e.clientX;
const startW = th.getBoundingClientRect().width;
const onMove = (ev) => {
const w = Math.max(48, Math.round(startW + (ev.clientX - startX)));
widths[colKey] = w;
th.style.width = w + 'px';
th.style.minWidth = w + 'px';
th.style.maxWidth = w + 'px';
const cg = document.getElementById('ra-devices-colgroup');
const col = cg && cg.querySelector('col[data-col="' + colKey + '"]');
if (col) col.style.width = w + 'px';
else if (colKey === 'actions') {
const actionCol = cg && cg.querySelector('col.col-actions');
if (actionCol) actionCol.style.width = w + 'px';
} else if (colKey === 'expand') {
const expandCol = cg && cg.querySelector('col.col-expand');
if (expandCol) expandCol.style.width = w + 'px';
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveColWidths(widths);
applyColgroup();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
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_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 formatAbis(abis) {
if (!Array.isArray(abis) || !abis.length) return '—';
return abis.join(', ');
}
function formatCell(key, d) {
switch (key) {
case 'device_name':
return (
'<code class="ra-device-id" title="' +
esc(d.device_id) +
'">' +
esc(deviceTitle(d)) +
'</code>'
);
case 'status':
return statusBadge(d);
case 'opt_in':
return esc(d.opt_in_mode || 'none');
case 'android_api':
return d.sdk_int != null && d.sdk_int !== '' ? esc(String(d.sdk_int)) : '—';
case 'android_version':
return esc(d.os_release || '—');
case 'last_seen':
return esc(d.last_seen_at || '—');
case 'app_version':
return esc(d.app_version || '—');
default:
return '—';
}
}
function detailLines(d) {
const lines = [];
lines.push('Device ID: ' + (d.device_id || '—'));
const name = (d.device_name || d.device_display || '').trim();
if (name) lines.push('Device name: ' + name);
if (d.manufacturer || d.model) {
lines.push('Hardware: ' + [d.manufacturer, d.model].filter(Boolean).join(' '));
}
if (d.brand) lines.push('Brand: ' + d.brand);
if (d.product) lines.push('Product: ' + d.product);
if (d.hardware_device) lines.push('Device codename: ' + d.hardware_device);
if (d.sdk_int != null) lines.push('Android API: ' + d.sdk_int);
if (d.os_release) lines.push('Android version: ' + d.os_release);
if (d.abis && d.abis.length) lines.push('ABIs: ' + formatAbis(d.abis));
if (d.lan_ip) lines.push('Device LAN IP: ' + d.lan_ip);
if (d.device_wan_ip) lines.push('Device WAN IP: ' + d.device_wan_ip);
if (d.poll_source_ip) {
let pollLabel = 'Poll source IP (server view)';
if (d.poll_source_is_private) {
pollLabel += ' — intra/private hop, not device WAN';
}
lines.push(pollLabel + ': ' + d.poll_source_ip);
}
if (d.vpn_ip) lines.push('VPN IP: ' + d.vpn_ip);
if (d.vpn_route_scope) lines.push('VPN route scope: ' + d.vpn_route_scope);
if (d.vpn_app_scope) lines.push('VPN app scope: ' + d.vpn_app_scope);
if (d.vpn_public_key) lines.push('VPN public key: ' + d.vpn_public_key);
if (d.wg_rx_bytes != null || d.wg_tx_bytes != null) {
const rx = formatBytes(d.wg_rx_bytes || 0);
const tx = formatBytes(d.wg_tx_bytes || 0);
lines.push('VPN traffic (BE wg): ↓' + rx + ' ↑' + tx);
}
if (d.wg_latest_handshake > 0) {
lines.push('VPN last handshake: ' + new Date(d.wg_latest_handshake * 1000).toISOString());
}
if (d.wg_endpoint) lines.push('VPN endpoint: ' + d.wg_endpoint);
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 isInteractiveTarget(el) {
return !!el.closest('.ra-device-actions, .ra-detail-links, .report-tree-toggle, button, a, input, select, textarea, label');
}
function bindRaTree(root) {
root.querySelectorAll('.report-row[data-device-id]').forEach((row) => {
const btn = row.querySelector('.report-tree-toggle');
const briefId = btn ? btn.getAttribute('aria-controls') : null;
const briefRow = briefId ? document.getElementById(briefId) : null;
if (!briefRow || !btn) return;
const toggle = (e) => {
if (e && isInteractiveTarget(e.target)) return;
const open = briefRow.hidden;
briefRow.hidden = !open;
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
row.classList.toggle('report-row--open', open);
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
toggle(e);
});
row.addEventListener('click', (e) => toggle(e));
row.addEventListener('keydown', (e) => {
if (isInteractiveTarget(e.target)) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle(e);
}
});
});
}
function captureExpandedDeviceIds() {
const ids = new Set();
const tbody = document.getElementById('ra-devices-tbody');
if (!tbody) return ids;
tbody.querySelectorAll('.report-row.report-row--open[data-device-id]').forEach((row) => {
const id = row.getAttribute('data-device-id');
if (id) ids.add(id);
});
return ids;
}
let lastDevices = [];
let lastExpanded = new Set();
function renderDevices(devices, expandedIds) {
const tbody = document.getElementById('ra-devices-tbody');
if (!tbody) return;
lastDevices = devices || [];
if (expandedIds) lastExpanded = expandedIds;
const cols = getDisplayColumns();
const span = colspan();
if (!devices || !devices.length) {
tbody.innerHTML =
'<tr><td colspan="' +
span +
'" class="muted">No devices have polled yet. Enable remote access on a device (dev settings) and wait for the next poll.</td></tr>';
return;
}
let html = '';
devices.forEach((d, i) => {
const rowKey = 'ra-' + i;
const briefId = 'ra-brief-' + rowKey;
const wl = Number(d.whitelisted) === 1;
const optIn = d.opt_in_mode || 'none';
const openReady = wl && (optIn === 'wireguard' || optIn === 'rssh');
const openHint = !canOperate()
? 'Need remote_access_operate permission'
: !wl
? 'Whitelist device first'
: openReady
? 'Open session — device connects on next poll (≤7 min)'
: 'Phone must poll with RSSH/WG enabled (dev settings on device; wait ≤7 min)';
const openDisabled = canOperate() ? '' : ' disabled';
const wlDisabled = canAdmin() ? '' : ' disabled';
const isOpen = lastExpanded.has(String(d.device_id || ''));
const rowClass =
'report-row' +
(d.needs_whitelist ? ' ra-device-row--needs-wl' : '') +
(isOpen ? ' report-row--open' : '');
html += '<tr class="' + rowClass + '" data-device-id="' + esc(d.device_id) + '" tabindex="0">';
html +=
'<td class="report-tree-cell"><button type="button" class="report-tree-toggle" aria-expanded="' +
(isOpen ? 'true' : 'false') +
'" aria-controls="' +
briefId +
'" title="Show device details"><span class="report-tree-arrow" aria-hidden="true"></span></button></td>';
cols.forEach((c) => {
html += '<td data-col="' + esc(c.key) + '">' + formatCell(c.key, d) + '</td>';
});
html +=
'<td class="ra-device-actions col-actions">' +
'<button type="button" class="btn btn-sm btn-primary"' +
openDisabled +
' data-open-session="' +
esc(d.device_id) +
'" title="' +
esc(openHint) +
'">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>';
html += '</tr>';
html +=
'<tr class="report-brief-row" id="' +
briefId +
'"' +
(isOpen ? '' : ' hidden') +
'><td colspan="' +
span +
'" 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 refreshTableChrome(devices, expandedIds) {
applyColgroup();
renderHead();
renderDevices(devices || lastDevices, expandedIds || lastExpanded);
}
function renderSessions(active, inactive) {
const aBody = document.getElementById('ra-active-tbody');
const iBody = document.getElementById('ra-inactive-tbody');
if (aBody) {
aBody.innerHTML = '';
if (!active || !active.length) {
aBody.innerHTML = '<tr><td colspan="6" class="muted">No pending or active sessions.</td></tr>';
}
(active || []).forEach((s) => {
const tr = document.createElement('tr');
const closeBtn = canOperate()
? '<button type="button" class="btn btn-sm" data-close-session="' + esc(s.session_id) + '" data-device="' + esc(s.device_id) + '">Close</button>'
: '';
let endpoint = s.endpoint || '—';
if (s.tunnel === 'ssh_reverse' && s.rssh_remote_bind) {
endpoint = endpoint + ' → ' + esc(s.rssh_remote_bind);
}
let operator = '';
if (s.rssh_operator) {
operator =
'<div class="ra-rssh-cmds muted"><code>' +
esc(s.rssh_operator.shell || '') +
'</code></div>';
}
tr.innerHTML =
'<td><code>' + esc(s.session_id) + '</code></td>' +
'<td><code>' + esc(s.device_id) + '</code></td>' +
'<td>' + esc(s.status) + '</td>' +
'<td>' + esc(s.tunnel) + '</td>' +
'<td>' + endpoint + operator + '</td>' +
'<td>' + closeBtn + '</td>';
aBody.appendChild(tr);
});
}
if (iBody) {
iBody.innerHTML = '';
(inactive || []).forEach((s) => {
const tr = document.createElement('tr');
tr.innerHTML =
'<td><code>' + esc(s.session_id) + '</code></td>' +
'<td><code>' + esc(s.device_id) + '</code></td>' +
'<td>' + esc(s.status) + '</td>' +
'<td>' + esc(s.closed_at || '—') + '</td>' +
'<td>' + esc(s.close_reason || '—') + '</td>';
iBody.appendChild(tr);
});
if (!inactive || !inactive.length) {
iBody.innerHTML = '<tr><td colspan="5" class="muted">No closed sessions yet.</td></tr>';
}
}
}
function renderEvents(events) {
const tbody = document.getElementById('ra-events-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!events || !events.length) {
tbody.innerHTML = '<tr><td colspan="4" class="muted">No audit events.</td></tr>';
return;
}
(events || []).forEach((e) => {
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' + esc(e.created_at) + '</td>' +
'<td><code>' + esc(e.device_id) + '</code></td>' +
'<td>' + esc(e.action) + '</td>' +
'<td>' + esc(e.reason || '—') + '</td>';
tbody.appendChild(tr);
});
}
let lastConfig = {};
async function refresh() {
const expanded = captureExpandedDeviceIds();
setStatus('Loading…');
try {
const data = await fetchJson(apiUrl('dashboard'));
lastConfig = data.config || {};
refreshTableChrome(data.devices, expanded);
renderSessions(data.active_sessions, data.inactive_sessions);
renderEvents(data.recent_events);
const ep = lastConfig.wg_endpoint ? ' · WG ' + lastConfig.wg_endpoint : '';
setStatus('Updated ' + new Date().toLocaleTimeString() + ep);
} catch (e) {
setStatus('Failed: ' + e.message, true);
}
}
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 {
await navigator.clipboard.writeText(copyId);
setStatus('Copied device ID');
} catch (e) {
setStatus('Copy failed', true);
}
return;
}
const openId = t.getAttribute('data-open-session');
if (openId) {
if (!canOperate()) return;
const dev = (lastDevices || []).find((d) => String(d.device_id) === openId);
const wl = dev && Number(dev.whitelisted) === 1;
const optIn = dev ? dev.opt_in_mode || 'none' : 'none';
if (!wl) {
setStatus('Whitelist device ' + openId + ' first', true);
return;
}
if (optIn !== 'wireguard' && optIn !== 'rssh') {
setStatus(
'Device opt-in is "' +
optIn +
'". On phone: dev settings → Remote access → RSSH, then wait for poll (≤7 min).',
true
);
return;
}
try {
setStatus('Opening session for ' + openId + '…');
await fetchJson(apiUrl('open_session'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: openId }),
});
setStatus('Session opened — device will connect on next poll (≤7 min)');
await refresh();
} catch (e) {
setStatus('Open session: ' + e.message, true);
}
return;
}
const closeId = t.getAttribute('data-close-session');
if (closeId) {
if (!canOperate()) return;
try {
await fetchJson(apiUrl('close_session'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: closeId,
device_id: t.getAttribute('data-device') || '',
}),
});
await refresh();
} catch (e) {
setStatus('Close session: ' + e.message, true);
}
return;
}
const wlDevice = t.getAttribute('data-toggle-wl');
if (wlDevice) {
if (!canAdmin()) return;
try {
await fetchJson(apiUrl('whitelist'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: wlDevice,
whitelisted: t.getAttribute('data-wl') === '1',
}),
});
await refresh();
} catch (e) {
setStatus('Whitelist: ' + e.message, true);
}
}
});
const form = document.getElementById('ra-whitelist-form');
if (form) {
if (!canAdmin()) form.hidden = true;
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
if (!canAdmin()) return;
const fd = new FormData(form);
try {
await fetchJson(apiUrl('whitelist'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: fd.get('device_id'),
notes: fd.get('notes'),
whitelisted: true,
}),
});
form.reset();
await refresh();
} catch (e) {
setStatus('Whitelist add: ' + e.message, true);
}
});
}
bindTableLayout();
applyColgroup();
renderHead();
refresh();
setInterval(refresh, 30000);
})();

2
public/index.php Normal file
View File

@@ -0,0 +1,2 @@
<?php
require __DIR__ . '/../views/layout.php';

825
views/layout.php Normal file
View File

@@ -0,0 +1,825 @@
<?php
/*
* package examples/crash_reporter/backend/views/layout.php
* layout.php
* Created at: Wed 20 May 2026 14:31:55 +0200
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 86 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 21e56cd12df6a94df41a1c0d67be530bd01454865ee1f032cef5f8d2e497509a
*/
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($pageTitle ?? 'Console') ?> — <?= h(cfg('app_name')) ?></title>
<script>
(function () {
var t = localStorage.getItem('crash_console_theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css">
<script src="<?= h(Auth::basePath()) ?>/assets/js/nav_shell.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/i18n.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/app.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/cookie_consent.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/ticket_create.js" defer></script>
<?php if (in_array($view ?? '', ['tickets', 'ticket'], true)): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/tickets.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'graphs'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/graphs.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'live_sessions'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/live_sessions.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'remote_access'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/remote_access.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'short_links'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/short_links.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'rbac'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/rbac_admin.js" defer></script>
<?php endif; ?>
<?php AnalyticsHead::render('crashes'); ?>
</head>
<body data-base-path="<?= h(console_base_path($view ?? null)) ?>"
data-view="<?= h($view ?? 'home') ?>"
data-can-tag-edit="<?= Auth::canEditTags() ? '1' : '0' ?>"
<?= (($view ?? '') === 'report' && !empty($report['id'])) ? ' data-report-id="' . (int) $report['id'] . '"' : '' ?>
<?= (($view ?? '') === 'ticket' && !empty($ticket['id'])) ? ' data-ticket-id="' . (int) $ticket['id'] . '"' : '' ?>
<?= Rbac::can('remote_access_operate') ? ' data-can-ra-operate="1"' : '' ?>
<?= Rbac::can('remote_access_admin') ? ' data-can-ra-admin="1"' : '' ?>
<?= Rbac::can('short_links_operate') ? ' data-can-sl-operate="1"' : '' ?>
<?= Rbac::isGlobalAdmin() ? ' data-can-sl-admin="1"' : '' ?>
<?= Rbac::isRoot() ? ' data-can-rbac-root="1"' : '' ?>>
<header class="top-menu" hidden aria-hidden="true"></header>
<div class="shell<?= ($view ?? '') === 'graphs' ? ' shell--graphs-full' : '' ?>">
<nav class="nav-pane" id="nav-pane" aria-label="Console navigation">
<button type="button" class="nav-handle" id="nav-handle" data-i18n-aria="nav.toggle" data-i18n-title="nav.toggle" aria-label="Toggle navigation" title="Navigation">
<span class="nav-icon nav-icon--menu" aria-hidden="true"></span>
</button>
<ul class="nav-list">
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=home"
class="nav-link <?= ($view ?? '') === 'home' ? 'active' : '' ?>"
data-i18n-aria="nav.home" data-i18n-title="nav.home"
aria-label="Home"
title="Home">
<span class="nav-icon nav-icon--home" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.home">Home</span>
<span class="nav-desc" data-i18n="nav.home_desc">Console home and workspace cards.</span>
</span>
</a>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=reports"
class="nav-link <?= ($view ?? '') === 'reports' || ($view ?? '') === 'report' ? 'active' : '' ?>"
data-i18n-aria="nav.reports" data-i18n-title="nav.reports"
aria-label="Reports"
title="Reports">
<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 issues.</span>
</span>
</a>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=tickets"
class="nav-link <?= ($view ?? '') === 'tickets' || ($view ?? '') === 'ticket' ? 'active' : '' ?>"
data-i18n-aria="nav.tickets" data-i18n-title="nav.tickets"
aria-label="Tickets"
title="Tickets">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.tickets">Tickets</span>
<span class="nav-desc" data-i18n="nav.tickets_desc">Roadmap tasks, QA items, and tags.</span>
</span>
</a>
</li>
<li>
<a href="/app/androidcast_project/graphs/"
class="nav-link <?= ($view ?? '') === 'graphs' ? 'active' : '' ?>"
aria-label="Analytics"
title="Analytics">
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.graphs">Analytics</span>
<span class="nav-desc" data-i18n="nav.graphs_desc">Sessions, issues, and device activity.</span>
</span>
</a>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=live_sessions"
class="nav-link <?= ($view ?? '') === 'live_sessions' ? 'active' : '' ?>"
aria-label="Live sessions"
title="Live sessions">
<span class="nav-icon nav-icon--live" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label">Live sessions</span>
<span class="nav-desc">Cast intents, join stats, and session history.</span>
</span>
</a>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/live/education"
class="nav-link"
aria-label="Education demo"
title="Education demo">
<span class="nav-icon nav-icon--education" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label">Education demo</span>
<span class="nav-desc">Browser screen-share trial (5 min).</span>
</span>
</a>
</li>
<?php if (Rbac::can('remote_access_view')): ?>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=remote_access"
class="nav-link <?= ($view ?? '') === 'remote_access' ? 'active' : '' ?>"
aria-label="Remote access"
title="Remote access">
<span class="nav-icon nav-icon--remote" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.remote">Remote access</span>
<span class="nav-desc" data-i18n="nav.remote_desc">WireGuard sessions and device reachability.</span>
</span>
</a>
</li>
<?php endif; ?>
<?php if (Rbac::can('short_links_view')): ?>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=short_links"
class="nav-link <?= ($view ?? '') === 'short_links' ? 'active' : '' ?>"
aria-label="Short links"
title="Short links">
<span class="nav-icon nav-icon--link" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="home.card_short_links">Short links</span>
<span class="nav-desc" data-i18n="home.card_short_links_desc">Mint bearer tokens and shorten URLs for s.f0xx.org.</span>
</span>
</a>
</li>
<?php endif; ?>
<?php if (Rbac::canManageRbac()): ?>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=rbac"
class="nav-link <?= ($view ?? '') === 'rbac' ? 'active' : '' ?>"
aria-label="Access control"
title="Access control">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.access">Access</span>
<span class="nav-desc" data-i18n="nav.access_desc">Roles, privilege sets, and operators.</span>
</span>
</a>
</li>
<?php endif; ?>
<li>
<a href="/app/androidcast_project/build/"
class="nav-link"
aria-label="Builder"
title="Builder">
<span class="nav-icon nav-icon--builder" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.builder">Builder</span>
<span class="nav-desc" data-i18n="nav.builder_desc">Docker CI for APK baking and OTA artifacts.</span>
</span>
</a>
</li>
</ul>
<div class="nav-locale">
<label class="toolbar-select locale-toolbar-select" data-i18n-title="lang.label" title="Language">
<span class="locale-flag" id="locale-flag" aria-hidden="true">🇬🇧</span>
<select class="lang-select" id="lang-select" data-i18n-aria="lang.label" aria-label="Language">
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
</label>
</div>
<div class="nav-user">
<span class="nav-user-name" title="<?= h(Auth::user()['username'] ?? '') ?>">
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
<span class="nav-label"><?= h(Auth::user()['username'] ?? '') ?></span>
</span>
<a href="<?= h(Auth::basePath()) ?>/account-security"
class="nav-link"
data-i18n-aria="nav.security" data-i18n-title="nav.security"
aria-label="Security"
title="Security">
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
<span class="nav-label" data-i18n="nav.security">Security</span>
</a>
<a href="<?= h(Auth::authUrl('/logout')) ?>"
class="nav-link nav-link--logout"
data-i18n-aria="nav.logout" data-i18n-title="nav.logout"
aria-label="Logout"
title="Logout">
<span class="nav-icon nav-icon--logout" aria-hidden="true"></span>
<span class="nav-label" data-i18n="nav.logout">Logout</span>
</a>
</div>
</nav>
<main class="main-pane">
<?php if (($view ?? 'home') === 'home'): ?>
<?php require __DIR__ . '/partials/console_home_landing.php'; ?>
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
<?php require __DIR__ . '/report_detail.php'; ?>
<?php elseif (($view ?? '') === 'ticket' && !empty($ticket)): ?>
<?php require __DIR__ . '/ticket_detail.php'; ?>
<?php elseif (($view ?? '') === 'tickets'): ?>
<div id="tickets-app" class="reports-app tickets-app">
<div class="toolbar reports-toolbar">
<h1 data-i18n="tickets.title">Tickets</h1>
<div class="toolbar-actions">
<button type="button" class="btn btn-primary js-new-ticket-btn" data-i18n="tickets.new">New ticket</button>
<label class="toolbar-select">
<span data-i18n="theme.label">Theme</span>
<select id="theme-select" aria-label="UI theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</label>
<label class="toolbar-select">
<span data-i18n="reports.per_page">Per page</span>
<select id="tickets-per-page" aria-label="Tickets per page">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="75">75</option>
<option value="100">100</option>
</select>
</label>
<label class="toolbar-select">
<span data-i18n="tickets.filter_tag">Status tag</span>
<select id="tickets-tag-filter" aria-label="Filter by tag">
<option value="" data-i18n="tickets.filter_all">All</option>
<option value="open">open</option>
<option value="in-progress">in progress</option>
<option value="confirmed">confirmed</option>
<option value="closed">closed</option>
<option value="urgent">urgent</option>
<option value="20260604">20260604 (roadmap)</option>
</select>
</label>
</div>
</div>
<p id="tickets-status" class="reports-status muted" aria-live="polite" data-i18n="reports.loading">Loading…</p>
<div class="reports-table-wrap">
<table class="data-table reports-tree reports-table--cols" id="tickets-table">
<thead>
<tr>
<th class="report-tree-head" aria-label="Expand"></th>
<th class="sortable" data-sort="title" scope="col" data-i18n="tickets.col_issue">Issue</th>
<th class="sortable sortable--active" data-sort="opened_at_ms" scope="col" data-i18n="tickets.col_opened">Opened</th>
<th scope="col" data-i18n="tickets.col_env">App / OS</th>
<th class="sortable" data-sort="rating" scope="col" data-i18n="col.rating">Rating</th>
<th class="col-tags" scope="col" data-i18n="col.tags">Tags</th>
</tr>
</thead>
<tbody id="tickets-tbody"></tbody>
</table>
</div>
<nav class="reports-pagination" id="tickets-pagination" aria-label="Tickets pages"></nav>
</div>
<?php elseif (($view ?? '') === 'reports'): ?>
<div id="reports-app" class="reports-app" data-grouped="0">
<div class="toolbar reports-toolbar">
<h1 data-i18n="reports.title">Issues</h1>
<div class="toolbar-actions">
<button type="button" class="btn btn-primary js-new-issue-btn" data-i18n="issues.new">New issue</button>
<button type="button" class="btn reports-mode-btn active" data-grouped="0" data-i18n="reports.by_time">By time</button>
<button type="button" class="btn reports-mode-btn" data-grouped="1" data-i18n="reports.grouped">Grouped</button>
<label class="toolbar-select">
<span data-i18n="theme.label">Theme</span>
<select id="theme-select" aria-label="UI theme" data-i18n-options="theme">
<option value="dark" data-i18n="theme.dark">Dark</option>
<option value="light" data-i18n="theme.light">Light</option>
</select>
</label>
<label class="toolbar-select tag-mode-select-wrap" id="tag-mode-wrap" hidden>
<span data-i18n="tag.filter_mode">Match</span>
<select id="tag-mode-select" aria-label="Tag filter mode">
<option value="and" data-i18n="tag.filter_and">All tags (AND)</option>
<option value="or" data-i18n="tag.filter_or">Any tag (OR)</option>
</select>
</label>
<label class="toolbar-select">
<span data-i18n="reports.per_page">Per page</span>
<select id="per-page-select" aria-label="Reports per page">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="75">75</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</label>
</div>
</div>
<div class="tag-filter-bar" id="tag-filter-bar">
<span class="muted tag-filter-label" data-i18n="tag.filter_label">Filter by tag</span>
<div class="tag-filter-chips" id="tag-filter-chips" role="group" aria-label="Tag filters"></div>
</div>
<p id="reports-filter-banner" class="reports-filter-banner muted" hidden></p>
<div class="reports-search-row">
<label class="reports-search-label" for="reports-search-input" data-i18n="reports.search">Search</label>
<div class="reports-search-field">
<input type="search" id="reports-search-input" class="reports-search-input"
data-i18n-placeholder="reports.search_placeholder"
placeholder="Keywords (exceptions, devices, stack traces…)" autocomplete="off" spellcheck="false">
<ul id="reports-search-suggest" class="reports-search-suggest" hidden role="listbox"></ul>
</div>
<button type="button" class="btn" id="reports-search-clear" hidden data-i18n="reports.clear">Clear</button>
</div>
<p id="reports-status" class="reports-status muted" aria-live="polite" data-i18n="reports.loading">Loading…</p>
<div class="reports-table-wrap">
<table class="data-table reports-tree reports-table--cols" id="reports-tree">
<colgroup id="reports-colgroup"></colgroup>
<thead id="reports-thead"></thead>
<tbody id="reports-tbody"></tbody>
</table>
</div>
<nav class="reports-pagination" id="reports-pagination" aria-label="Reports pages"></nav>
</div>
<?php elseif (($view ?? '') === 'graphs'): ?>
<div id="graphs-app" class="reports-app graphs-app">
<div class="toolbar reports-toolbar">
<h1>AndroidCast analytics</h1>
<div class="toolbar-actions">
<label class="toolbar-select">
<span>Theme</span>
<select id="theme-select" aria-label="UI theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</label>
<label class="toolbar-select">
<span>Window</span>
<select id="graphs-days" aria-label="Graphs window">
<option value="1">1d</option>
<option value="7">7d</option>
<option value="14" selected>14d</option>
<option value="30">30d</option>
</select>
</label>
<label class="toolbar-select">
<span>Columns</span>
<select id="graphs-columns" aria-label="Graph grid columns">
<option value="1">1</option>
<option value="2" selected>2</option>
<option value="4">4</option>
</select>
</label>
</div>
</div>
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
<p id="graphs-session-notice" class="graphs-session-notice" role="status" hidden data-i18n="graphs.session_deficit">
Session charts are empty. On the device: Developer settings → enable “Grab session stats”, cast a session, then wait for upload to graph_upload.php.
</p>
<p id="graphs-status" class="reports-status muted" aria-live="polite">Loading…</p>
<section class="graphs-scope" data-graph-scope="user">
<h2 class="graphs-scope-title">Your activity</h2>
<div class="cards cards--lift graphs-grid">
<article class="card card--lift"><h3>Sessions / day</h3><canvas id="graph-user-sessions" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Unique devices / day</h3><canvas id="graph-user-devices" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Avg duration (s) / day</h3><canvas id="graph-user-duration" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Send sessions / day</h3><canvas id="graph-user-send" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Receive sessions / day</h3><canvas id="graph-user-recv" width="520" height="160"></canvas></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>Inbound kbps / day</h3><canvas id="graph-user-recv-kbps" 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>
<section class="graphs-scope" data-graph-scope="slug_admin" hidden>
<h2 class="graphs-scope-title">Company (slug admin)</h2>
<div class="cards cards--lift graphs-grid">
<article class="card card--lift"><h3>Sessions / day</h3><canvas id="graph-slug-sessions" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Unique devices / day</h3><canvas id="graph-slug-devices" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Install sources</h3><canvas id="graph-slug-install-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>App versions</h3><div id="graph-slug-versions" class="graph-breakdown"></div></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>
<section class="graphs-scope" data-graph-scope="platform_admin" hidden>
<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>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 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>
<section class="graphs-scope" id="graphs-live-cast-section">
<h2 class="graphs-scope-title">Live cast</h2>
<div class="cards cards--lift graphs-grid">
<article class="card card--lift"><h3>Live casts / day</h3><canvas id="graph-live-casts" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Avg cast length (s) / day</h3><canvas id="graph-live-avg-length" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Cast platforms</h3><canvas id="graph-live-platform-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>Join platforms</h3><canvas id="graph-live-join-platform-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>Video codecs</h3><canvas id="graph-live-video-codec-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>Audio codecs</h3><canvas id="graph-live-audio-codec-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>Bandwidth modes</h3><canvas id="graph-live-bw-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>Top casters</h3><div id="graph-live-top-users" class="graph-breakdown"></div></article>
</div>
</section>
<div id="graph-detail-overlay" class="graph-detail-overlay" hidden>
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="graph-detail-title">
<header class="graph-detail-header">
<div class="graph-detail-header-text">
<h2 id="graph-detail-title">Graph</h2>
<p id="graph-detail-stats" class="graph-detail-stats muted" aria-live="polite"></p>
</div>
<button type="button" class="btn graph-detail-close" id="graph-detail-close" aria-label="Close full view">Close</button>
<button type="button" class="btn" id="graph-detail-copy-link">Copy link</button>
<a class="btn" id="graph-detail-share" href="#" hidden aria-hidden="true"></a>
</header>
<div class="graph-detail-canvas-wrap">
<canvas id="graph-detail-canvas" hidden></canvas>
<div id="graph-detail-body" class="graph-detail-body"></div>
<p id="graph-detail-tooltip" class="graph-detail-tooltip" hidden></p>
</div>
</div>
</div>
</div>
<?php elseif (($view ?? '') === 'live_sessions'): ?>
<div id="live-sessions-app" class="reports-app">
<div class="toolbar reports-toolbar">
<h1>Live cast sessions</h1>
<div class="toolbar-actions">
<label class="toolbar-select">
<span>Window</span>
<select id="live-sessions-days" aria-label="Sessions window">
<option value="1">1d</option>
<option value="7">7d</option>
<option value="14" selected>14d</option>
<option value="30">30d</option>
</select>
</label>
<a class="btn" href="<?= h(Auth::basePath()) ?>/live/education">Education demo</a>
</div>
</div>
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
<p id="live-sessions-status" class="reports-status muted" aria-live="polite">Loading…</p>
<p class="muted graphs-footnote">Read-only session tree for all signed-in users. Sorted by last heartbeat (most recent first).</p>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="live-sessions-table">
<thead>
<tr>
<th>Status</th>
<th>User</th>
<th>Platform</th>
<th>Video</th>
<th>Duration</th>
<th>Last heartbeat</th>
<th>Join opens</th>
<th>Link</th>
</tr>
</thead>
<tbody id="live-sessions-tbody"></tbody>
</table>
</div>
</div>
<?php elseif (($view ?? '') === 'remote_access'): ?>
<div id="remote-access-app" class="reports-app">
<div class="toolbar reports-toolbar">
<h1>Remote access</h1>
<div class="toolbar-actions">
<label class="toolbar-select">
<span>Theme</span>
<select id="theme-select" aria-label="UI theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</label>
</div>
</div>
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
<p id="ra-status" class="reports-status muted" aria-live="polite">Loading…</p>
<section class="graphs-scope">
<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.
<strong>Poll source IP</strong> is the address BE sees on the HTTP request (often a router or proxy on <code>10.7.x.x</code>) — not the device WAN or LAN.
</p>
<div class="reports-table-wrap">
<table class="data-table reports-tree reports-table--cols" id="ra-devices-table">
<colgroup id="ra-devices-colgroup"></colgroup>
<thead id="ra-devices-thead"></thead>
<tbody id="ra-devices-tbody"></tbody>
</table>
</div>
<form id="ra-whitelist-form" class="tag-editor-add" style="margin-top:1rem">
<label class="tag-field"><span>Device ID</span><input type="text" name="device_id" required maxlength="128"></label>
<label class="tag-field"><span>Notes</span><input type="text" name="notes" maxlength="255"></label>
<button type="submit" class="btn">Add / whitelist</button>
</form>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Active / pending sessions</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="ra-active-table">
<thead>
<tr>
<th scope="col">Session</th>
<th scope="col">Device</th>
<th scope="col">Status</th>
<th scope="col">Tunnel</th>
<th scope="col">Endpoint</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="ra-active-tbody"></tbody>
</table>
</div>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Inactive / closed sessions</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="ra-inactive-table">
<thead>
<tr>
<th scope="col">Session</th>
<th scope="col">Device</th>
<th scope="col">Status</th>
<th scope="col">Closed</th>
<th scope="col">Reason</th>
</tr>
</thead>
<tbody id="ra-inactive-tbody"></tbody>
</table>
</div>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Audit log</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="ra-events-table">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Device</th>
<th scope="col">Action</th>
<th scope="col">Reason</th>
</tr>
</thead>
<tbody id="ra-events-tbody"></tbody>
</table>
</div>
</section>
</div>
<?php elseif (($view ?? '') === 'short_links'): ?>
<div id="short-links-app" class="reports-app">
<div class="toolbar reports-toolbar">
<h1>Short links</h1>
<div class="toolbar-actions">
<label class="toolbar-select">
<span>Theme</span>
<select id="theme-select" aria-label="UI theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</label>
</div>
</div>
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
<p id="sl-status" class="reports-status muted" aria-live="polite">Loading…</p>
<p class="muted graphs-footnote">
Public API: <code>https://s.f0xx.org/api/v1/shorten</code> · machine clients use bearer tokens minted here.
</p>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Bearer tokens</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="sl-bearers-table">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Label</th>
<th scope="col">Status</th>
<th scope="col">Caps</th>
<th scope="col">Links</th>
<th scope="col">Rate/h</th>
<th scope="col">Created</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="sl-bearers-tbody"></tbody>
</table>
</div>
<form id="sl-mint-form" class="tag-editor-add" style="margin-top:1rem"<?= Rbac::can('short_links_operate') ? '' : ' hidden' ?>>
<label class="tag-field"><span>Label</span><input type="text" name="label" required maxlength="128" placeholder="prod-hub"></label>
<label class="tag-field"><span>Rate limit / hour</span><input type="number" name="rate_limit" value="1000" min="1" max="100000"></label>
<label class="tag-field tag-field--check"><span><input type="checkbox" name="can_temporary" value="1" checked> Temporary links</span></label>
<label class="tag-field tag-field--check"><span><input type="checkbox" name="can_permanent" value="1" id="sl-can-permanent"> Permanent links</span></label>
<label class="tag-field tag-field--check" id="sl-mint-signer-wrap" hidden><span><input type="checkbox" name="mint_signer" value="1" checked> Mint signer key</span></label>
<button type="submit" class="btn btn-primary">Mint bearer</button>
<?php if (Rbac::isGlobalAdmin()): ?>
<button type="button" class="btn" id="sl-purge-btn">Purge expired</button>
<?php endif; ?>
</form>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Signer keys (permanent links)</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="sl-signers-table">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Label</th>
<th scope="col">Status</th>
<th scope="col">Created</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="sl-signers-tbody"></tbody>
</table>
</div>
<form id="sl-mint-signer-form" class="tag-editor-add" style="margin-top:1rem"<?= Rbac::can('short_links_operate') ? '' : ' hidden' ?>>
<label class="tag-field"><span>Label</span><input type="text" name="label" required maxlength="128" placeholder="prod-perm"></label>
<button type="submit" class="btn">Mint signer</button>
</form>
</section>
<section class="graphs-scope" id="sl-links-section" hidden>
<h2 class="graphs-scope-title">Links for bearer <code id="sl-links-bearer-label">—</code></h2>
<form id="sl-shorten-form" class="tag-editor-add" style="margin-bottom:1rem"<?= Rbac::can('short_links_operate') ? '' : ' hidden' ?>>
<input type="hidden" name="bearer_id" id="sl-shorten-bearer-id" value="">
<label class="tag-field tag-field--wide"><span>Long URL</span><input type="url" name="url" required maxlength="2048" placeholder="https://example.com/path"></label>
<label class="tag-field tag-field--check"><span><input type="checkbox" name="permanent" value="1" id="sl-permanent-cb"> Permanent (ttl=0)</span></label>
<label class="tag-field" id="sl-ttl-wrap"><span>TTL (seconds)</span><input type="number" name="ttl" id="sl-ttl-input" value="86400" min="60" max="31536000"></label>
<label class="tag-field tag-field--wide" id="sl-signer-wrap" hidden><span>Signer key</span><input type="password" name="signer" id="sl-signer-input" autocomplete="off" placeholder="required for permanent"></label>
<button type="submit" class="btn btn-primary">Shorten</button>
</form>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="sl-links-table">
<thead>
<tr>
<th scope="col">Short URL</th>
<th scope="col">Origin</th>
<th scope="col">Expires</th>
<th scope="col">Hits</th>
<th scope="col">QR</th>
</tr>
</thead>
<tbody id="sl-links-tbody"></tbody>
</table>
</div>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Audit log</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="sl-audit-table">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Event</th>
<th scope="col">Bearer</th>
<th scope="col">Slug</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody id="sl-audit-tbody"></tbody>
</table>
</div>
</section>
<div id="sl-token-modal" class="graph-detail-overlay" hidden>
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="sl-token-title">
<header class="graph-detail-header">
<h2 id="sl-token-title">Bearer token — copy now</h2>
<button type="button" class="btn graph-detail-close" id="sl-token-close">Close</button>
</header>
<p class="muted">Shown once. Store in a secrets manager or <code>BEARER=…</code> for curl scripts.</p>
<p><code id="sl-token-value" style="word-break:break-all"></code></p>
<button type="button" class="btn btn-primary" id="sl-token-copy">Copy token</button>
</div>
</div>
<div id="sl-signer-modal" class="graph-detail-overlay" hidden>
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="sl-signer-title">
<header class="graph-detail-header">
<h2 id="sl-signer-title">Signer key — copy now</h2>
<button type="button" class="btn graph-detail-close" id="sl-signer-close">Close</button>
</header>
<p class="muted">Shown once. Required for <code>ttl=0</code> permanent links with a bearer that has permanent capability.</p>
<p><code id="sl-signer-value" style="word-break:break-all"></code></p>
<button type="button" class="btn btn-primary" id="sl-signer-copy">Copy signer</button>
</div>
</div>
</div>
<?php elseif (($view ?? '') === 'rbac'): ?>
<div id="rbac-app" class="reports-app">
<div class="toolbar reports-toolbar">
<h1>Access control</h1>
</div>
<p id="rbac-status" class="reports-status muted" aria-live="polite">Loading…</p>
<p id="rbac-scope-hint" class="muted graphs-footnote"></p>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Users &amp; company roles</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="rbac-users-table">
<thead>
<tr>
<th scope="col">User</th>
<th scope="col">Global role</th>
<th scope="col">Company</th>
<th scope="col">Company role</th>
<th scope="col">Privilege set</th>
<th scope="col">Auth lockouts</th>
</tr>
</thead>
<tbody id="rbac-users-tbody"></tbody>
</table>
</div>
</section>
<p class="muted graphs-footnote">
API: <code><?= h(Auth::basePath()) ?>/api/rbac.php</code> ·
Root edits global roles; company owner/admin edits memberships and remote-access privilege sets.
</p>
</div>
<?php endif; ?>
</main>
</div>
<?php if (Auth::canEditTags()): ?>
<div id="tag-modal" class="tag-modal" hidden aria-hidden="true">
<div class="tag-modal-backdrop" data-tag-modal-close></div>
<div class="tag-modal-panel" role="dialog" aria-labelledby="tag-modal-title">
<header class="tag-modal-header">
<h2 id="tag-modal-title" data-i18n="tag.edit_title">Edit tags</h2>
<button type="button" class="tag-modal-close" data-tag-modal-close data-i18n-aria="tag.close" aria-label="Close">×</button>
</header>
<div class="tag-editor tag-editor--modal" id="tag-editor-modal" data-report-id="0">
<div class="tag-editor-preview" id="tag-modal-preview"></div>
<ul class="tag-editor-list" id="tag-modal-list"></ul>
<form class="tag-editor-add" id="tag-modal-add">
<label class="tag-field"><span data-i18n="tag.label">Label</span><input type="text" name="label" maxlength="40" required></label>
<label class="tag-field"><span data-i18n="tag.color">Color</span><input type="color" name="bg" value="#5c6b82"></label>
<button type="submit" class="btn" data-i18n="tag.add">Add</button>
</form>
<div class="tag-preset-bar" id="tag-modal-presets" hidden>
<span class="muted" data-i18n="tag.suggestions">Suggestions:</span>
<div class="tag-preset-chips" id="tag-modal-preset-chips"></div>
</div>
<div class="tag-editor-actions">
<button type="button" class="btn btn-primary" id="tag-modal-save">Save</button>
<span class="tag-editor-status muted" id="tag-modal-status"></span>
</div>
</div>
</div>
</div>
<?php endif; ?>
<dialog id="ticket-create-dialog" class="ticket-create-dialog">
<form method="dialog" id="ticket-create-form" class="ticket-create-form">
<header class="ticket-create-header">
<h2 id="ticket-create-heading" data-i18n="tickets.new">New ticket</h2>
<button type="button" class="ticket-create-close" id="ticket-create-close" data-i18n-aria="tag.close" aria-label="Close">×</button>
</header>
<div class="ticket-create-body">
<label><span data-i18n="ticket.title">Title</span>
<input name="title" id="ticket-create-title" required maxlength="256" autofocus></label>
<label><span data-i18n="ticket.brief">Brief</span>
<input name="brief" id="ticket-create-brief" maxlength="512"></label>
<label><span data-i18n="ticket.body">Description</span>
<textarea name="body" id="ticket-create-body" rows="6"></textarea></label>
</div>
<footer class="ticket-create-footer">
<p id="ticket-create-status" class="muted" aria-live="polite"></p>
<div class="ticket-create-actions">
<button type="button" class="btn" id="ticket-create-cancel" data-i18n="ticket.cancel">Cancel</button>
<button type="submit" class="btn btn-primary" data-i18n="ticket.create">Create</button>
</div>
</footer>
</form>
</dialog>
<?php require __DIR__ . '/partials/cookie_consent.php'; ?>
<?php platform_render_footer(); ?>
</body>
</html>

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
$bp = Auth::basePath();
$proj = '/app/androidcast_project';
?>
<div class="console-home">
<div class="toolbar reports-toolbar console-home-toolbar">
<h1 data-i18n="home.title">Console</h1>
<div class="toolbar-actions">
<label class="toolbar-select">
<span data-i18n="theme.label">Theme</span>
<select id="theme-select" aria-label="UI theme" data-i18n-options="theme">
<option value="dark" data-i18n="theme.dark">Dark</option>
<option value="light" data-i18n="theme.light">Light</option>
</select>
</label>
</div>
</div>
<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 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>
<h2 data-i18n="issues.new">New issue</h2>
<p class="muted" data-i18n="home.card_new_issue_desc">Log a new issue or task in one step.</p>
</button>
<a href="<?= h($bp) ?>/?view=tickets" class="card card--lift console-home-card">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<h2 data-i18n="nav.tickets">Tickets</h2>
<p class="muted" data-i18n="home.card_tickets_desc">Roadmap tasks, QA items, and workflow tags.</p>
</a>
<button type="button" class="card card--lift console-home-card console-home-card--btn js-new-ticket-btn">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<h2 data-i18n="tickets.new">New ticket</h2>
<p class="muted" data-i18n="home.card_new_ticket_desc">Create a roadmap or QA ticket in one step.</p>
</button>
<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, 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">
<span class="nav-icon nav-icon--link" aria-hidden="true"></span>
<h2 data-i18n="home.card_short_links">Short links</h2>
<p class="muted" data-i18n="home.card_short_links_desc">Mint bearer tokens and shorten URLs for s.f0xx.org.</p>
</a>
<?php endif; ?>
</div>
<h2 class="console-home-more" data-i18n="home.more">All services</h2>
<?php $skip_home_link = true; require __DIR__ . '/console_quick_links.php'; ?>
</div>

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/**
* Horizontal service links with the same icons as the left nav (8px gap).
* @var string|null $extra_class optional class on <nav>
* @var bool $skip_home_link omit "Console home" (use on home view)
*/
$bp = Auth::basePath();
$proj = '/app/androidcast_project';
$navClass = 'console-quick-links graphs-quick-links' . (isset($extra_class) ? ' ' . $extra_class : '');
$items = [];
if (empty($skip_home_link)) {
$items[] = ['Console home', $bp . '/?view=home', 'nav-icon--home'];
}
$items[] = ['Issues', $bp . '/?view=reports', 'nav-icon--reports'];
$items[] = ['Tickets', $bp . '/?view=tickets', 'nav-icon--tickets'];
if (Rbac::can('remote_access_view')) {
$items[] = ['Remote access', $bp . '/?view=remote_access', 'nav-icon--remote'];
}
if (Rbac::can('short_links_view')) {
$items[] = ['Short links', $bp . '/?view=short_links', 'nav-icon--link'];
}
if (Rbac::canManageRbac()) {
$items[] = ['Access control', $bp . '/?view=rbac', 'nav-icon--tickets'];
}
$items[] = ['Graphs', $proj . '/graphs/', 'nav-icon--graphs'];
$items[] = ['Builder', $proj . '/build/', 'nav-icon--builder'];
$items[] = ['Hub', $proj . '/', 'nav-icon--home'];
?>
<nav class="<?= h($navClass) ?>" aria-label="Related services">
<?php foreach ($items as [$label, $href, $icon]): ?>
<a href="<?= h($href) ?>" class="console-quick-link">
<span class="nav-icon <?= h($icon) ?>" aria-hidden="true"></span>
<span><?= h($label) ?></span>
</a>
<?php endforeach; ?>
</nav>

View File

@@ -0,0 +1,15 @@
<?php declare(strict_types=1); ?>
<div id="cookie-consent-banner" class="cookie-consent" hidden role="dialog" aria-labelledby="cookie-consent-title" aria-modal="false">
<div class="cookie-consent-inner">
<p id="cookie-consent-title" class="cookie-consent-title">Cookies on AndroidCast</p>
<p class="cookie-consent-text muted">
We use necessary session cookies to sign you in. With your consent we also load analytics to improve the service.
You can change this later in your browser by clearing site data.
</p>
<div class="cookie-consent-actions">
<button type="button" class="btn btn--primary" id="cookie-consent-all">Accept all</button>
<button type="button" class="btn" id="cookie-consent-necessary">Necessary only</button>
<button type="button" class="btn btn--ghost" id="cookie-consent-reject">Reject optional</button>
</div>
</div>
</div>