mirror of
git://f0xx.org/ac/ac-be-remote-access
synced 2026-07-29 00:58:46 +03:00
initial
This commit is contained in:
3
README.md
Normal file
3
README.md
Normal 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
19
composer.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
801
public/assets/js/remote_access.js
Normal file
801
public/assets/js/remote_access.js
Normal 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
2
public/index.php
Normal file
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
require __DIR__ . '/../views/layout.php';
|
||||
825
views/layout.php
Normal file
825
views/layout.php
Normal 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 & 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>
|
||||
58
views/partials/console_home_landing.php
Normal file
58
views/partials/console_home_landing.php
Normal 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>
|
||||
37
views/partials/console_quick_links.php
Normal file
37
views/partials/console_quick_links.php
Normal 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>
|
||||
15
views/partials/cookie_consent.php
Normal file
15
views/partials/cookie_consent.php
Normal 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>
|
||||
Reference in New Issue
Block a user