vibes
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
// 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());
|
||||
Reference in New Issue
Block a user