431 lines
20 KiB
JavaScript
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());
|