(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' || optIn === 'iodine'); 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 WireGuard/RSSH/Iodine 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' && optIn !== 'iodine') { setStatus( 'Device opt-in is "' + optIn + '". On phone: dev settings → Remote access → WireGuard, RSSH, or Iodine, 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); })();