// Halcyon admin console: the accounts, and an audit trail of what they did. import { h, clear, toast, download } from '/dom.js'; import { hexView, parseHex } from '/hexview.js'; import { createMap } from '/admin/map.js'; const root = document.getElementById('app'); const ACTION_LOOK = { register: ['lime', 'REGISTER'], login: ['cyan', 'LOGIN'], code_save: ['amber', 'CODE SAVE'], code_clear: ['dim', 'CODE CLEAR'], program_upload: ['violet', 'PROGRAM UPLOAD'], launch: ['mag', 'LAUNCH'], scrap: ['red', 'SCRAP'], uplink: ['cyan', 'UPLINK'], }; const state = { actions: [], accounts: [], sort: { key: 'id', dir: 1 }, selected: null, tab: 'accounts', audit: { rows: [], player: '', action: '', done: false, loading: false }, auto: null, map: null, }; // ---- plumbing --------------------------------------------------------------- class SessionEnded extends Error {} async function call(path, { method = 'GET', body } = {}) { const res = await fetch(`/admin/api${path}`, { method, credentials: 'same-origin', headers: body === undefined ? {} : { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); if (res.status === 401 && path !== '/login') { showLogin('YOUR SESSION HAS ENDED. LOG IN AGAIN.'); throw new SessionEnded(); } if (!res.ok) { let msg = `${res.status} ${res.statusText}`; try { msg = (await res.json()).error ?? msg; } catch { /* not JSON */ } const e = new Error(msg); e.status = res.status; throw e; } return res.status === 204 ? null : res.json(); } const pad2 = (n) => String(n).padStart(2, '0'); function stamp(sec) { if (!sec) return h('span', { class: 'dim' }, 'NEVER'); const d = new Date(sec * 1000); const text = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; return h('span', { title: `${d.toISOString()} (UTC)` }, text); } function ago(sec) { if (!sec) return 'never'; const s = Math.max(0, Math.floor(Date.now() / 1000 - sec)); if (s < 90) return `${s}s ago`; if (s < 5400) return `${Math.round(s / 60)}m ago`; if (s < 129600) return `${Math.round(s / 3600)}h ago`; return `${Math.round(s / 86400)}d ago`; } const shortHash = (x) => (typeof x === 'string' ? `${x.slice(0, 10)}…` : ''); /** One line describing an audit entry. */ function summary(e) { const d = e.detail && typeof e.detail === 'object' ? e.detail : {}; switch (e.action) { case 'register': return `created the account "${d.name ?? ''}" with ship #${e.ship}`; case 'login': return `signed in${d.user_agent ? ` (${d.user_agent})` : ''}`; case 'code_save': return `saved slot ${e.slot} "${d.name ?? ''}": ${d.bytes} bytes, sha256 ${shortHash(d.sha256)}`; case 'code_clear': return `cleared slot ${e.slot}`; case 'program_upload': return `uploaded a ${d.bytes}-byte program to ship #${e.ship}, sha256 ${shortHash(d.sha256)}`; case 'launch': return e.slot ? `launched ship #${e.ship} with slot ${e.slot} "${d.slot_name ?? ''}" (${d.program_bytes} bytes)` : `launched ship #${e.ship} with the program already sealed in it`; case 'scrap': return `scrapped ship #${e.ship}; a new ship replaces it on the next run`; case 'uplink': { const hex = typeof d.hex === 'string' ? d.hex : ''; const preview = hex.slice(0, 32).match(/../g)?.join(' ').toUpperCase() ?? ''; return `sent ship #${e.ship} ${d.bytes} bytes${preview ? `: ${preview}${hex.length > 32 ? ' …' : ''}` : ''}`; } default: return JSON.stringify(e.detail); } } const badge = (action) => { const [cls, label] = ACTION_LOOK[action] ?? ['', action.toUpperCase()]; return h('span', { class: `badge ${cls}` }, label); }; // ---- login ------------------------------------------------------------------ function showLogin(message = '') { stopAuto(); const err = h('p', { class: 'form-error', role: 'alert' }, message); const pw = h('input', { id: 'pw', type: 'password', autocomplete: 'current-password', required: true, spellcheck: 'false' }); const btn = h('button', { class: 'btn primary wide', type: 'submit' }, 'ENTER'); clear(root).append( h('div', { class: 'a-login' }, h('h1', null, 'HALCYON ', h('b', null, 'ADMIN')), h('form', { class: 'panel', onsubmit: async (ev) => { ev.preventDefault(); btn.disabled = true; err.textContent = ''; try { await call('/login', { method: 'POST', body: { password: pw.value } }); pw.value = ''; await boot(); } catch (e) { if (e instanceof SessionEnded) return; err.textContent = e.status === 401 ? 'WRONG PASSWORD' : `ERROR: ${e.message}`.toUpperCase(); btn.disabled = false; pw.select(); } }, }, h('div', { class: 'panel-title' }, 'OPERATOR LOGIN'), h('div', { class: 'panel-body stack' }, h('label', { for: 'pw' }, 'ADMIN PASSWORD'), pw, err, btn), ), ), ); pw.focus(); } // ---- console ---------------------------------------------------------------- async function boot() { try { const s = await call('/session'); state.actions = s.actions; } catch (e) { if (e instanceof SessionEnded) return; if (e.status === 404) { clear(root).append(h('div', { class: 'a-login' }, h('div', { class: 'panel' }, h('div', { class: 'panel-title' }, 'ADMIN IS OFF'), h('p', { class: 'panel-body' }, 'SET THE HALCYON_ADMIN_PASSWORD ENVIRONMENT VARIABLE ON THE SERVER TO TURN THE ADMIN CONSOLE ON.')))); return; } showLogin(e.message); return; } render(); await loadAccounts(); } function render() { stopAuto(); state.map?.destroy(); // a map from an earlier visit to the tab state.map = null; clear(root).append( h('header', { class: 'a-top' }, h('span', { class: 'a-brand' }, 'HALCYON // ', h('b', null, 'ADMIN')), h('span', { class: 'a-note' }, 'OPERATOR CONSOLE. WHAT YOU SEE HERE IS PRIVATE.'), h('span', { class: 'spacer' }), h('button', { class: 'btn small', type: 'button', onclick: async () => { try { await call('/logout', { method: 'POST' }); } catch { /* already out */ } showLogin('LOGGED OUT.'); } }, 'LOGOUT'), ), h('main', { class: 'a-main', id: 'main' }, h('div', { class: 'a-tabs', role: 'tablist' }, [['accounts', 'ACCOUNTS'], ['audit', 'AUDIT LOG'], ['map', 'MAP']].map(([t, label]) => h('button', { class: `tab ${state.tab === t ? 'on' : ''}`, type: 'button', role: 'tab', 'aria-selected': String(state.tab === t), onclick: () => { state.tab = t; render(); if (t === 'audit') loadAudit(true); else if (t === 'accounts') loadAccounts(); } }, label))), h('div', { id: 'view' }), ), ); if (state.tab === 'accounts') renderAccounts(); else if (state.tab === 'audit') renderAudit(); else renderMap(); } function renderMap() { state.map = createMap({ fetchMap: () => call('/map').catch((e) => { throw e instanceof SessionEnded ? Object.assign(e, { name: 'SessionEnded' }) : e; }), fetchOrbit: (shipId) => call(`/map/orbit?ship=${shipId}`).catch((e) => { throw e instanceof SessionEnded ? Object.assign(e, { name: 'SessionEnded' }) : e; }), onOpenAccount: (id) => { state.selected = id; state.tab = 'accounts'; render(); loadAccounts(); }, }); clear(view()).append(state.map.el); state.map.load(); } const view = () => document.getElementById('view'); // ---- accounts --------------------------------------------------------------- async function loadAccounts() { try { state.accounts = await call('/accounts'); if (state.tab === 'accounts') renderAccounts(); else fillAccountOptions(); } catch (e) { if (!(e instanceof SessionEnded)) toast(`COULD NOT LOAD ACCOUNTS: ${e.message}`, 'err'); } } const COLS = [ ['id', '#', 'num'], ['name', 'CALLSIGN', ''], ['credits', 'CREDITS', 'num'], ['ships', 'SHIPS', 'num'], ['in_belt', 'IN BELT', 'num'], ['slots_used', 'SLOTS', 'num'], ['actions', 'ACTIONS', 'num'], ['last_login', 'LAST LOGIN', 'nowrap'], ['last_action', 'LAST ACTION', 'nowrap'], ]; let filterText = ''; function renderAccounts() { const v = clear(view()); const search = h('input', { type: 'text', id: 'q', placeholder: 'FILTER BY CALLSIGN', value: filterText, 'aria-label': 'Filter accounts by callsign', autocomplete: 'off', spellcheck: 'false' }); const tableHost = h('div', { class: 'scroll' }); const count = h('span', { class: 'dim' }); const draw = () => { const { key, dir } = state.sort; const q = filterText.trim().toLowerCase(); const rows = state.accounts .filter((a) => !q || a.name.toLowerCase().includes(q) || String(a.id) === q) .sort((a, b) => (typeof a[key] === 'string' ? a[key].localeCompare(b[key]) : a[key] - b[key]) * dir || a.id - b.id); count.textContent = `${rows.length} OF ${state.accounts.length} ACCOUNTS`; clear(tableHost).append( rows.length === 0 ? h('p', { class: 'a-empty' }, 'NO ACCOUNTS MATCH.') : h('table', { class: 'atable' }, h('thead', null, h('tr', null, COLS.map(([k, label]) => h('th', { scope: 'col', 'aria-sort': state.sort.key === k ? (dir > 0 ? 'ascending' : 'descending') : null }, h('button', { type: 'button', onclick: () => { state.sort = { key: k, dir: state.sort.key === k ? -dir : 1 }; draw(); } }, label + (state.sort.key === k ? (dir > 0 ? ' ▲' : ' ▼') : '')))))), h('tbody', null, rows.map((a) => h('tr', { class: `arow ${state.selected === a.id ? 'sel' : ''}`, tabindex: '0', onclick: () => select(a.id), onkeydown: (e) => { if (e.key === 'Enter') select(a.id); } }, h('td', { class: 'num' }, String(a.id)), h('td', null, a.name), h('td', { class: 'num' }, a.credits.toLocaleString('en-US')), h('td', { class: 'num' }, String(a.ships)), h('td', { class: 'num' }, String(a.in_belt)), h('td', { class: 'num' }, `${a.slots_used}/13`), h('td', { class: 'num' }, String(a.actions)), h('td', { class: 'nowrap', title: a.last_login ? '' : 'has not signed in since logins were recorded' }, a.last_login ? `${ago(a.last_login)}` : h('span', { class: 'dim' }, '--')), h('td', { class: 'nowrap' }, a.last_action ? ago(a.last_action) : h('span', { class: 'dim' }, '--')), )))), ); }; search.addEventListener('input', () => { filterText = search.value; draw(); }); v.append( h('section', { class: 'panel' }, h('div', { class: 'panel-title' }, 'ACCOUNTS', h('span', { class: 'spacer' }), h('button', { class: 'btn small', type: 'button', onclick: loadAccounts }, 'REFRESH')), h('div', { class: 'a-filters' }, h('label', null, search), count), tableHost, ), h('div', { id: 'detail' }), ); draw(); if (state.selected) select(state.selected, false); } async function select(id, redraw = true) { state.selected = id; if (redraw) renderAccounts(); const host = document.getElementById('detail'); if (!host) return; try { const d = await call(`/accounts/${id}`); if (state.selected !== id) return; const code = h('pre', { class: 'code-view', hidden: true }); const codeTitle = h('div', { class: 'panel-title', hidden: true }); clear(host).append(h('div', { class: 'a-detail a-grid' }, h('section', { class: 'panel' }, h('div', { class: 'panel-title' }, `ACCOUNT #${d.id}`, h('span', { class: 'spacer' }), h('button', { class: 'btn small', type: 'button', onclick: () => { state.audit.player = String(d.id); state.audit.action = ''; state.tab = 'audit'; render(); loadAudit(true); } }, 'FULL AUDIT')), h('dl', { class: 'kv' }, h('dt', null, 'CALLSIGN'), h('dd', null, d.name), h('dt', null, 'CREDITS'), h('dd', null, d.credits.toLocaleString('en-US')), ), h('div', { class: 'panel-title' }, 'SHIPS'), d.ships.length ? h('div', { class: 'scroll' }, h('table', { class: 'atable' }, h('thead', null, h('tr', null, ['SHIP', 'STATUS', 'PROGRAM', 'LAST DOWNLINK'].map((t) => h('th', { scope: 'col' }, t)))), h('tbody', null, d.ships.map((s) => h('tr', null, h('td', null, `#${s.id}`), h('td', null, s.status.toUpperCase()), h('td', null, s.program_bytes ? `${s.program_bytes} BYTES` : '--'), h('td', null, s.downlink_day >= 0 ? `DAY ${s.downlink_day}` : '--')))))) : h('p', { class: 'a-empty' }, 'NO SHIPS.'), h('div', { class: 'panel-title' }, 'CODE SLOTS'), d.slots.length ? h('div', { class: 'scroll' }, h('table', { class: 'atable' }, h('thead', null, h('tr', null, ['SLOT', 'NAME', 'SIZE', 'SAVED', ''].map((t) => h('th', { scope: 'col' }, t)))), h('tbody', null, d.slots.map((s) => h('tr', null, h('td', null, String(s.slot)), h('td', null, s.name), h('td', { class: 'num' }, `${s.size}`), h('td', { class: 'nowrap' }, s.updated_at ? new Date(s.updated_at).toLocaleString() : '--'), h('td', null, h('button', { class: 'btn small', type: 'button', onclick: async () => { try { const src = await call(`/accounts/${d.id}/slots/${s.slot}`); codeTitle.hidden = code.hidden = false; codeTitle.textContent = `SLOT ${s.slot}: ${src.name} (READ ONLY)`; code.textContent = src.source; code.scrollIntoView({ block: 'nearest' }); } catch (e) { if (!(e instanceof SessionEnded)) toast(e.message, 'err'); } } }, 'VIEW CODE'))))))) : h('p', { class: 'a-empty' }, 'NO CODE SAVED.'), codeTitle, code, ), h('section', { class: 'panel' }, h('div', { class: 'panel-title' }, 'RECENT ACTIVITY'), d.audit.length ? h('div', { class: 'scroll' }, auditTable(d.audit, { showAccount: false })) : h('p', { class: 'a-empty' }, 'NOTHING RECORDED YET.'), ), )); } catch (e) { if (!(e instanceof SessionEnded)) toast(`COULD NOT LOAD THE ACCOUNT: ${e.message}`, 'err'); } } // ---- audit ------------------------------------------------------------------ function auditTable(rows, { showAccount = true } = {}) { const open = new Set(); const tbody = h('tbody'); const draw = () => { clear(tbody); for (const e of rows) { const tr = h('tr', { class: 'arow', tabindex: '0', 'aria-expanded': String(open.has(e.id)), onclick: () => { open.has(e.id) ? open.delete(e.id) : open.add(e.id); draw(); }, onkeydown: (ev) => { if (ev.key === 'Enter') tr.click(); } }, h('td', { class: 'nowrap' }, stamp(e.at)), showAccount ? h('td', null, `${e.name || '?'} `, h('span', { class: 'dim' }, `#${e.player_id}`)) : null, h('td', null, badge(e.action)), h('td', null, summary(e)), h('td', { class: 'nowrap dim' }, e.ip || ''), ); tbody.append(tr); if (open.has(e.id)) { const d = e.detail && typeof e.detail === 'object' ? e.detail : {}; const cell = h('td', { colspan: showAccount ? '5' : '4' }, h('pre', { class: 'detail-json' }, JSON.stringify({ id: e.id, at: new Date(e.at * 1000).toISOString(), account: e.player_id, action: e.action, ship: e.ship || undefined, slot: e.slot || undefined, ip: e.ip, detail: e.detail }, null, 2))); if (e.action === 'uplink' && typeof d.hex === 'string') { try { cell.append(hexView(parseHex(d.hex), { maxFont: 13 }).el); } catch { /* not hex: the JSON above shows it */ } } tbody.append(h('tr', { class: 'more' }, cell)); } } }; draw(); return h('table', { class: 'atable' }, h('thead', null, h('tr', null, ['TIME', showAccount ? 'ACCOUNT' : null, 'ACTION', 'WHAT', 'FROM'].filter(Boolean).map((t) => h('th', { scope: 'col' }, t)))), tbody); } const PAGE = 100; async function loadAudit(reset) { const a = state.audit; if (a.loading) { // A request is in flight. If this one is a fresh load (a filter changed), do it as // soon as that finishes, with the filter as it is by then; extra "load more" clicks are dropped. if (reset) a.reload = true; return; } a.reload = false; if (reset) { a.rows = []; a.done = false; } a.loading = true; try { const q = new URLSearchParams({ limit: String(PAGE) }); if (a.player) q.set('player', a.player); if (a.action) q.set('action', a.action); if (!reset && a.rows.length) q.set('before', String(a.rows[a.rows.length - 1].id)); const rows = await call(`/audit?${q}`); a.rows = reset ? rows : a.rows.concat(rows); a.done = rows.length < PAGE; } catch (e) { if (!(e instanceof SessionEnded)) toast(`COULD NOT LOAD THE AUDIT LOG: ${e.message}`, 'err'); } finally { a.loading = false; } if (a.reload) return loadAudit(true); // a filter changed while we were loading if (state.tab === 'audit') renderAuditList(); } function stopAuto() { if (state.auto) { clearInterval(state.auto); state.auto = null; } } /** The account dropdown, kept in step with the loaded accounts without replacing it. */ function fillAccountOptions() { const sel = document.getElementById('f-player'); if (!sel) return; const keep = state.audit.player; sel.replaceChildren( h('option', { value: '' }, 'ALL ACCOUNTS'), ...state.accounts.map((x) => h('option', { value: x.id, selected: String(x.id) === keep }, `${x.name} #${x.id}`)), ); sel.value = keep; } // The filter bar is built once when the tab opens and never replaced, so a control // keeps its focus and a change made while results are loading is never lost. Only the // list underneath is redrawn. function renderAudit() { const a = state.audit; const v = clear(view()); const accountSel = h('select', { id: 'f-player', 'aria-label': 'Account' }); const actionSel = h('select', { id: 'f-action', 'aria-label': 'Action' }, h('option', { value: '' }, 'ALL ACTIONS'), state.actions.map((x) => h('option', { value: x, selected: x === a.action }, (ACTION_LOOK[x]?.[1] ?? x.toUpperCase())))); accountSel.addEventListener('change', () => { a.player = accountSel.value; loadAudit(true); }); actionSel.addEventListener('change', () => { a.action = actionSel.value; loadAudit(true); }); const auto = h('input', { type: 'checkbox', id: 'auto', checked: !!state.auto }); auto.addEventListener('change', () => { stopAuto(); if (auto.checked) state.auto = setInterval(() => { if (state.tab === 'audit' && !a.loading) loadAudit(true); }, 10000); }); v.append(h('section', { class: 'panel' }, h('div', { class: 'panel-title' }, 'AUDIT LOG', h('span', { class: 'spacer' }), h('button', { class: 'btn small', type: 'button', onclick: () => loadAudit(true) }, 'REFRESH')), h('div', { class: 'a-filters' }, h('label', null, 'ACCOUNT', accountSel), h('label', null, 'ACTION', actionSel), h('label', { class: 'check', for: 'auto' }, auto, 'AUTO-REFRESH (10 S)'), h('span', { class: 'spacer' }), h('button', { class: 'btn small', id: 'csv', type: 'button', disabled: true, onclick: () => download('halcyon-audit.csv', toCSV(state.audit.rows), 'text/csv') }, 'DOWNLOAD CSV')), h('div', { id: 'audit-list' }), )); fillAccountOptions(); if (!state.accounts.length) loadAccounts(); renderAuditList(); } function renderAuditList() { const a = state.audit; const host = document.getElementById('audit-list'); if (!host) return; const csv = document.getElementById('csv'); if (csv) csv.disabled = a.rows.length === 0; clear(host).append( a.rows.length ? h('div', { class: 'scroll' }, auditTable(a.rows)) : h('p', { class: 'a-empty' }, a.loading ? 'LOADING...' : 'NOTHING RECORDED FOR THIS FILTER.'), h('div', { class: 'a-foot' }, h('span', null, `${a.rows.length} ENTRIES SHOWN`), a.done ? h('span', null, 'THAT IS EVERYTHING.') : h('button', { class: 'btn small', type: 'button', disabled: a.loading || a.rows.length === 0, onclick: () => loadAudit(false) }, 'LOAD MORE')), ); } const csvCell = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; function toCSV(rows) { const head = ['id', 'time_utc', 'account_id', 'account', 'action', 'ship', 'slot', 'ip', 'detail']; return `${[head.join(','), ...rows.map((e) => [e.id, new Date(e.at * 1000).toISOString(), e.player_id, e.name, e.action, e.ship, e.slot, e.ip, JSON.stringify(e.detail)].map(csvCell).join(','))].join('\n')}\n`; } // ---- start ------------------------------------------------------------------ boot().catch(() => showLogin());