commit 4500fc5e0a776eaa262949961da99643857e55c5 Author: Anton Afanasyeu Date: Tue Jun 23 12:29:36 2026 +0200 initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..f341a2a --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ac-be-remote-access + +Thin BE UI. Remote: `git://f0xx.org/ac/ac-be-remote-access` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0c4f059 --- /dev/null +++ b/composer.json @@ -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" + } + ] +} diff --git a/public/assets/js/remote_access.js b/public/assets/js/remote_access.js new file mode 100644 index 0000000..b4aa098 --- /dev/null +++ b/public/assets/js/remote_access.js @@ -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 = + ''; + order.forEach((k) => { + html += + ''; + }); + html += + ''; + 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 = + ''; + cols.forEach((c) => { + const w = widths[c.key] ? ' style="width:' + widths[c.key] + 'px"' : ''; + html += + '' + + '' + + '' + + esc(c.label) + + '' + + ''; + }); + html += + 'Actions'; + 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 'Needs whitelist'; + } + if (label === 'whitelisted') { + return 'Whitelisted'; + } + if (label === 'stale') { + return 'Stale'; + } + if (label === 'not_opted_in') { + return 'Not opted in'; + } + return 'Polling'; + } + + function deviceTitle(d) { + const name = (d.device_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 ( + '' + + esc(deviceTitle(d)) + + '' + ); + 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 = ''; + 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 = + 'No devices have polled yet. Enable remote access on a device (dev settings) and wait for the next poll.'; + 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 += ''; + html += + ''; + + cols.forEach((c) => { + html += '' + formatCell(c.key, d) + ''; + }); + + html += + '' + + ' ' + + ' ' + + '' + + ''; + html += ''; + + html += + '
'; + detailLines(d).forEach((line) => { + html += '

' + esc(line) + '

'; + }); + html += detailLinks(d); + html += '
'; + }); + 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 = 'No pending or active sessions.'; + } + (active || []).forEach((s) => { + const tr = document.createElement('tr'); + const closeBtn = canOperate() + ? '' + : ''; + 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 = + '
' + + esc(s.rssh_operator.shell || '') + + '
'; + } + tr.innerHTML = + '' + esc(s.session_id) + '' + + '' + esc(s.device_id) + '' + + '' + esc(s.status) + '' + + '' + esc(s.tunnel) + '' + + '' + endpoint + operator + '' + + '' + closeBtn + ''; + aBody.appendChild(tr); + }); + } + if (iBody) { + iBody.innerHTML = ''; + (inactive || []).forEach((s) => { + const tr = document.createElement('tr'); + tr.innerHTML = + '' + esc(s.session_id) + '' + + '' + esc(s.device_id) + '' + + '' + esc(s.status) + '' + + '' + esc(s.closed_at || '—') + '' + + '' + esc(s.close_reason || '—') + ''; + iBody.appendChild(tr); + }); + if (!inactive || !inactive.length) { + iBody.innerHTML = 'No closed sessions yet.'; + } + } + } + + function renderEvents(events) { + const tbody = document.getElementById('ra-events-tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + if (!events || !events.length) { + tbody.innerHTML = 'No audit events.'; + return; + } + (events || []).forEach((e) => { + const tr = document.createElement('tr'); + tr.innerHTML = + '' + esc(e.created_at) + '' + + '' + esc(e.device_id) + '' + + '' + esc(e.action) + '' + + '' + esc(e.reason || '—') + ''; + 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); +})(); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..e8271bc --- /dev/null +++ b/public/index.php @@ -0,0 +1,2 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (2 commits, 86 lines) + * - Cursor Agent (project assistant) + * Digest: SHA256 21e56cd12df6a94df41a1c0d67be530bd01454865ee1f032cef5f8d2e497509a + */ +?> + + + + + + <?= h($pageTitle ?? 'Console') ?> — <?= h(cfg('app_name')) ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + > + +
+ +
+ + + + + + + +
+
+

Tickets

+
+ + + + +
+
+

Loading…

+
+ + + + + + + + + + + + +
IssueOpenedApp / OSRatingTags
+
+ +
+ +
+
+

Issues

+
+ + + + + + +
+
+
+ Filter by tag +
+
+ +
+ +
+ + +
+ +
+

Loading…

+
+ + + + +
+
+ +
+ +
+
+

AndroidCast analytics

+
+ + + +
+
+ + +

Loading…

+ +
+

Your activity

+
+

Sessions / day

+

Unique devices / day

+

Avg duration (s) / day

+

Send sessions / day

+

Receive sessions / day

+

Success ratio

+

Outbound kbps / day

+

Inbound kbps / day

+

Issues / day

+
+
+ + + + + +
+

Live cast

+
+

Live casts / day

+

Avg cast length (s) / day

+

Cast platforms

+

Join platforms

+

Video codecs

+

Audio codecs

+

Bandwidth modes

+

Top casters

+
+
+ + +
+ +
+
+

Live cast sessions

+
+ + Education demo +
+
+ +

Loading…

+

Read-only session tree for all signed-in users. Sorted by last heartbeat (most recent first).

+
+ + + + + + + + + + + + + + +
StatusUserPlatformVideoDurationLast heartbeatJoin opensLink
+
+
+ +
+
+

Remote access

+
+ +
+
+ +

Loading…

+ +
+

Devices

+

+ Devices appear after they poll remote access (dev settings → WireGuard or RSSH). Sort is by last seen — top rows are actively polling. + Whitelist rows marked Needs whitelist (opted in, not yet allowed). + Stale rows have not polled in 7+ days. + Poll source IP is the address BE sees on the HTTP request (often a router or proxy on 10.7.x.x) — not the device WAN or LAN. +

+
+ + + + +
+
+
+ + + +
+
+ +
+

Active / pending sessions

+
+ + + + + + + + + + + + +
SessionDeviceStatusTunnelEndpointActions
+
+
+ +
+

Inactive / closed sessions

+
+ + + + + + + + + + + +
SessionDeviceStatusClosedReason
+
+
+ +
+

Audit log

+
+ + + + + + + + + + +
TimeDeviceActionReason
+
+
+
+ + + +
+
+

Access control

+
+

Loading…

+

+
+

Users & company roles

+
+ + + + + + + + + + + + +
UserGlobal roleCompanyCompany rolePrivilege setAuth lockouts
+
+
+

+ API: /api/rbac.php · + Root edits global roles; company owner/admin edits memberships and remote-access privilege sets. +

+
+ +
+
+ + + + +
+
+

New ticket

+ +
+
+ + + +
+
+

+
+ + +
+
+
+
+ + + + diff --git a/views/partials/console_home_landing.php b/views/partials/console_home_landing.php new file mode 100644 index 0000000..b8d660a --- /dev/null +++ b/views/partials/console_home_landing.php @@ -0,0 +1,58 @@ + +
+
+

Console

+
+ +
+
+

Issue triage, tickets, and session metrics — pick a workspace below.

+ + + +

All services

+ +
diff --git a/views/partials/console_quick_links.php b/views/partials/console_quick_links.php new file mode 100644 index 0000000..9106fd4 --- /dev/null +++ b/views/partials/console_quick_links.php @@ -0,0 +1,37 @@ + + * @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']; +?> + diff --git a/views/partials/cookie_consent.php b/views/partials/cookie_consent.php new file mode 100644 index 0000000..0a0e5ed --- /dev/null +++ b/views/partials/cookie_consent.php @@ -0,0 +1,15 @@ + +