Files
2026-10-01 22:16:48 +02:00

431 lines
20 KiB
JavaScript

// 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());