Add telemetry dashboard and automatic server names; record Workshop 0.0.137
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-11-01 19:29:54 +01:00
parent b8dfa9184e
commit 6ba3036de6
23 changed files with 1509 additions and 16 deletions
@@ -0,0 +1,44 @@
// Receipt times come from the receiver, never the game server's clock. A late
// queued report is fresh receipt evidence, not proof that the server is online.
export async function dashboardOverview(db, serverId) {
const where = serverId ? ' WHERE server_id = ?' : '';
const values = serverId ? [serverId] : [];
const results = await db.batch([
db.prepare(`SELECT COUNT(*) AS missions, COALESCE(SUM(outcome = 'victory'), 0) AS wins,
COALESCE(SUM(outcome = 'defeat'), 0) AS losses, COALESCE(SUM(terminal = 0), 0) AS unfinished,
COALESCE(SUM(partial), 0) AS partialMissions FROM missions${where}`).bind(...values),
db.prepare(`SELECT COUNT(*) AS servers FROM servers${where}`).bind(...values),
db.prepare(`SELECT COUNT(*) AS events, MAX(received_at) AS lastReceivedAt FROM events${where}`).bind(...values),
db.prepare(`SELECT CASE WHEN terminal = 1 THEN outcome ELSE 'unfinished' END AS outcome,
COUNT(*) AS missions FROM missions${where} GROUP BY terminal, outcome`).bind(...values),
db.prepare(`SELECT lower(json_extract(payload, '$.terrain')) AS terrain, COUNT(*) AS missions,
SUM(outcome = 'victory') AS wins, SUM(outcome = 'defeat') AS losses,
SUM(terminal = 0) AS unfinished FROM missions${where}
GROUP BY lower(json_extract(payload, '$.terrain')) ORDER BY missions DESC, terrain LIMIT 101`).bind(...values),
]);
const totals = {...results[0].results[0], ...results[1].results[0], ...results[2].results[0]};
const completed = totals.wins + totals.losses;
totals.winRate = completed ? Math.round(1000 * totals.wins / completed) / 10 : null;
return {serverId, generatedAt: Math.floor(Date.now() / 1000), totals,
outcomes: results[3].results, maps: results[4].results.slice(0, 100), mapsTruncated: results[4].results.length > 100};
}
export async function dashboardServers(db, {limit, before}, query) {
const result = await db.prepare(`WITH page AS (
SELECT rowid AS cursor, * FROM servers WHERE rowid < ?
AND (? = '' OR instr(lower(name), lower(?)) > 0 OR instr(server_id, lower(?)) > 0)
ORDER BY rowid DESC LIMIT ?
) SELECT s.cursor, s.server_id AS serverId, s.name, s.active, s.registration, s.created_at AS createdAt,
e.received_at AS lastReceivedAt, json_extract(e.payload, '$.occurredAt') AS lastReportedAt,
json_extract(e.payload, '$.type') AS lastEventType, e.mission_id AS lastMissionId,
json_extract(e.payload, '$.terrain') AS terrain,
json_extract(e.payload, '$.connectedPlayers') AS lastReportedPlayers,
COUNT(m.id) AS missions, COALESCE(SUM(m.outcome = 'victory'), 0) AS wins,
COALESCE(SUM(m.outcome = 'defeat'), 0) AS losses,
COALESCE(SUM(m.terminal = 0), 0) AS unfinished, COALESCE(SUM(m.partial), 0) AS partialMissions
FROM page s LEFT JOIN events e ON e.id = (
SELECT id FROM events WHERE server_id = s.server_id ORDER BY received_at DESC, id DESC LIMIT 1)
LEFT JOIN missions m ON m.server_id = s.server_id
GROUP BY s.cursor ORDER BY s.cursor DESC`).bind(before, query, query, query, limit).all();
return {servers: result.results, nextBefore: result.results.length === limit ? result.results.at(-1).cursor : null};
}
@@ -0,0 +1,209 @@
// Serialized as a standalone same-origin browser script. Never interpolate
// telemetry into HTML; names, reasons and arbitrary settings are text nodes.
export function dashboardApp() {
const $ = id => document.getElementById(id);
const state = {token: '', view: 'overview', server: '', serverName: '', outcome: '', terrain: '', query: '', cursors: [null], page: 0};
let controller, detailController;
const labels = {victory: 'Victory', defeat: 'Defeat', unfinished: 'Unfinished', preparation_failed: 'Preparation failed', restarted_empty: 'Empty restart', interrupted: 'Interrupted'};
const phases = ['Preparing', 'Prison', 'Radio search', 'Radio defence', 'Extraction', 'Victory', 'Defeat'];
const number = value => value == null ? '—' : Number(value).toLocaleString();
const date = value => value == null ? 'Never received' : new Date(value * 1000).toLocaleString();
const age = value => {
if (value == null) return 'No reports yet';
const seconds = Math.max(0, Math.floor(Date.now() / 1000 - value));
if (seconds < 60) return 'Just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
};
const duration = value => value == null ? '—' : `${Math.floor(value / 3600)}h ${Math.floor(value % 3600 / 60)}m ${Math.floor(value % 60)}s`;
function el(tag, text, className) {
const node = document.createElement(tag);
if (text != null) node.textContent = String(text);
if (className) node.className = className;
return node;
}
function button(text, action, className = 'link') {
const node = el('button', text, className); node.type = 'button'; node.addEventListener('click', action); return node;
}
function stack(text, sub, mono = false) {
const node = el('div', text); node.append(el('span', sub, mono ? 'sub mono' : 'sub')); return node;
}
function timestamp(value) {
const node = stack(age(value), date(value));
if (value != null) node.title = new Date(value * 1000).toISOString();
return node;
}
function badge(outcome) {
return el('span', labels[outcome] || outcome, `badge ${outcome === 'victory' ? 'good' : outcome === 'defeat' ? 'bad' : 'warn'}`);
}
function table(headers, rows) {
const wrap = el('div', null, 'table-wrap'), node = el('table'), head = el('thead'), tr = el('tr'), body = el('tbody');
headers.forEach(h => tr.append(el('th', h))); head.append(tr); node.append(head, body); wrap.append(node);
rows.forEach(cells => { const row = el('tr'); cells.forEach(value => { const cell = el('td'); cell.append(value instanceof Node ? value : document.createTextNode(value == null ? '—' : String(value))); row.append(cell); }); body.append(row); });
if (!rows.length) { const cell = el('td', 'No records match this selection.', 'empty'); cell.colSpan = headers.length; const row = el('tr'); row.append(cell); body.append(row); }
return wrap;
}
function params(extra = {}) {
const query = new URLSearchParams();
if (state.server) query.set('serverId', state.server);
Object.entries(extra).forEach(([key, value]) => { if (value != null && value !== '') query.set(key, value); });
return `?${query}`;
}
async function api(path, signal) {
const response = await fetch(path, {headers: {Authorization: `Bearer ${state.token}`}, cache: 'no-store', signal});
if (response.status === 401) throw new Error('Token rejected. Sign out and enter a valid read or administrator token.');
if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || `Request failed (${response.status}).`); }
return response.json();
}
function resetPages() { state.page = 0; state.cursors = [null]; }
function selectServer(serverId, name) {
state.server = serverId; state.serverName = name || serverId; state.view = 'missions'; resetPages(); refresh();
}
function renderMetrics(data) {
const t = data.totals, cards = [
['Reporting servers', number(t.servers), `${number(t.events)} stored reports`],
['Missions recorded', number(t.missions), `${number(t.unfinished)} without a final result`],
['Successful escapes', number(t.wins), `${number(t.losses)} failed missions`],
['Win rate', t.winRate == null ? '—' : `${t.winRate}%`, 'Completed gameplay missions only'],
];
$('metrics').replaceChildren(...cards.map(([label, value, note]) => { const card = el('div', null, 'metric'); card.append(el('div', label, 'metric-label'), el('div', value, 'metric-value'), el('div', note, 'metric-note')); return card; }));
}
function renderOverview(data) {
const grid = el('div', null, 'overview-grid'), left = el('div'), right = el('div');
left.append(el('h2', 'Mission outcomes'), el('p', 'One result per mission. Startup failures and empty restarts remain separate.', 'small'));
for (const outcome of Object.keys(labels)) {
const count = data.outcomes.find(row => row.outcome === outcome)?.missions || 0;
const row = el('div', null, 'outcome-row'), label = el('div', null, 'outcome-label'), bar = el('div', null, 'bar'), fill = el('i', null, outcome === 'victory' ? 'good' : outcome === 'defeat' ? 'bad' : '');
label.append(el('span', labels[outcome]), el('span', number(count))); fill.style.width = `${data.totals.missions ? 100 * count / data.totals.missions : 0}%`; bar.append(fill); row.append(label, bar); left.append(row);
}
left.append(el('h3', 'Latest report received'), timestamp(data.totals.lastReceivedAt), el('p', `${number(data.totals.partialMissions)} missions have incomplete telemetry coverage.`, 'small'));
right.append(el('h2', 'By terrain'), el('p', 'Select a terrain to explore its missions. Historical world paths are preserved as reported.', 'small'));
right.append(table(['Terrain', 'Missions', 'Won', 'Failed', 'Unfinished'], data.maps.map(map => [button(map.terrain || 'Unknown terrain', () => { state.terrain = map.terrain || ''; state.view = 'missions'; resetPages(); refresh(); }), number(map.missions), number(map.wins), number(map.losses), number(map.unfinished)])));
if (data.mapsTruncated) right.append(el('p', 'Showing the 100 terrains with the most missions. Use the terrain filter in Missions to find others.', 'notice'));
grid.append(left, right); $('content').replaceChildren(grid);
}
function pager(next) {
const node = el('div', null, 'pager'), controls = el('div');
const previous = button('← Previous', () => { state.page--; refresh(); }, 'secondary'); previous.disabled = state.page === 0;
const forward = button('Next →', () => { state.cursors[state.page + 1] = next; state.page++; refresh(); }, 'secondary'); forward.disabled = next == null;
controls.append(previous, forward); node.append(el('span', `Page ${state.page + 1} · Up to 25 records per page`), controls); return node;
}
function toolbar(title, description) {
const node = el('div', null, 'toolbar'), heading = el('div'); heading.append(el('h2', title), el('span', description, 'small muted')); node.append(heading); return node;
}
function textFilter(label, value) {
const node = el('label', label), input = el('input'); input.value = value; node.append(input); return {node, input};
}
function renderServers(data) {
const top = toolbar('Reporting servers', 'Registration is permission to submit data, not an online status.'), form = el('form', null, 'filters');
const query = textFilter('Server name or ID', state.query); query.input.maxLength = 120;
const submit = el('button', 'Search', 'secondary'); form.append(query.node, submit); form.addEventListener('submit', e => { e.preventDefault(); state.query = query.input.value.trim(); resetPages(); refresh(); }); top.append(form);
const rows = data.servers.map(server => {
const name = el('div'); name.append(button(server.name || 'Unnamed server', () => selectServer(server.serverId, server.name)), el('span', server.serverId, 'sub mono'));
const registration = stack(server.active ? 'Enabled' : 'Revoked', server.registration);
const results = stack(`${number(server.wins)} won / ${number(server.losses)} failed`, `${number(server.missions)} missions · ${number(server.unfinished)} unfinished`);
const last = timestamp(server.lastReceivedAt); last.append(el('span', server.lastEventType || 'No event', 'sub'));
const latest = server.lastMissionId ? button(server.terrain || 'Unknown terrain', () => showMission(server.serverId, server.lastMissionId)) : '—';
return [name, results, last, stack(number(server.lastReportedPlayers), 'at last report'), latest, registration];
});
$('content').replaceChildren(top, table(['Server', 'Mission record', 'Last received', 'Players', 'Latest reported mission', 'Ingestion'], rows), pager(data.nextBefore));
}
function missionServer(m) {
const node = el('div');
node.append(button(m.serverName || 'Unnamed server', () => selectServer(m.serverId, m.serverName)), el('span', m.serverId, 'sub mono'));
return node;
}
function renderMissions(data) {
const top = toolbar('Mission history', 'Newest created first. Select a mission to inspect its complete report.'), form = el('form', null, 'filters');
const outcomeLabel = el('label', 'Outcome'), select = el('select');
for (const [value, label] of [['', 'All outcomes'], ...Object.entries(labels)]) { const opt = el('option', label); opt.value = value; select.append(opt); }
select.value = state.outcome; outcomeLabel.append(select);
const terrain = textFilter('Exact terrain / world path', state.terrain); terrain.input.maxLength = 1024;
form.append(outcomeLabel, terrain.node, el('button', 'Apply', 'secondary')); form.addEventListener('submit', e => { e.preventDefault(); state.outcome = select.value; state.terrain = terrain.input.value.trim(); resetPages(); refresh(); }); top.append(form);
const rows = data.missions.map(m => {
const title = el('div'); title.append(button(m.terrain || 'Unknown terrain', () => showMission(m.serverId, m.missionId)), el('span', m.missionId, 'sub mono'));
const flags = [m.partial ? 'Partial coverage' : '', m.playersTruncated ? 'Player list truncated' : '', m.droppedEvents ? `${m.droppedEvents} dropped reports` : ''].filter(Boolean).join(' · ');
return [title, badge(m.outcome), missionServer(m), duration(m.elapsedSeconds), number(m.peakPlayers), timestamp(m.receivedAt), flags || 'Complete report'];
});
$('content').replaceChildren(top, table(['Terrain / mission', 'Outcome', 'Server', 'Duration', 'Peak players', 'Selected snapshot received', 'Coverage'], rows), pager(data.nextBefore));
}
function counter(p, key, coverage) {
return stack(number(p[key]), `${number(p[coverage])}/${number(p.attempts)} missions reported this counter`);
}
function renderPlayers(data) {
const top = toolbar('Player records', 'Authenticated participants in completed gameplay missions. Names are available in individual mission reports.');
const rows = data.players.map(p => [el('span', p.playerId, 'mono'), number(p.attempts), stack(number(p.escapes), `${number(p.didNotEscape)} did not escape`), duration(p.bestEscapeSeconds), counter(p, 'kills', 'missionsWithKills'), counter(p, 'friendlyKills', 'missionsWithFriendlyKills'), counter(p, 'deaths', 'missionsWithDeaths'), counter(p, 'downed', 'missionsWithDowned'), counter(p, 'recoveries', 'missionsWithRecoveries'), `${number(p.partialMissions)} partial / ${number(p.truncatedMissions)} truncated`]);
$('content').replaceChildren(top, table(['Player ID', 'Attempts', 'Escapes', 'Best escape', 'Kills', 'Friendly kills', 'Deaths', 'Downed', 'Recoveries', 'Coverage'], rows), el('p', 'Counter coverage means the field was present, not that a kill or injury happened. A dash means no recorded value.', 'small muted'), pager(data.nextAfter));
}
async function refresh() {
controller?.abort(); const run = new AbortController(); controller = run; $('refresh').disabled = true;
$('error').hidden = true; $('updated').textContent = 'Fetching reports…';
$('scope-label').textContent = state.server ? `Server: ${state.serverName}` : 'All reporting servers'; $('clear-scope').hidden = !state.server;
document.querySelectorAll('#tabs button').forEach(node => { if (node.dataset.view === state.view) node.setAttribute('aria-current', 'page'); else node.removeAttribute('aria-current'); });
// Do not leave another server's rows visible beneath a newly selected scope.
$('content').replaceChildren(el('p', 'Loading records…', 'empty')); $('metrics').replaceChildren();
try {
const cursor = state.cursors[state.page];
const paths = {servers: `/api/servers${params({limit: 25, before: cursor, q: state.query})}`, missions: `/api/missions${params({limit: 25, before: cursor, outcome: state.outcome, terrain: state.terrain})}`, players: `/api/players${params({limit: 25, after: cursor})}`};
const [summary, data] = await Promise.all([api(`/api/dashboard${params()}`, run.signal), state.view === 'overview' ? Promise.resolve(null) : api(paths[state.view], run.signal)]);
if (run.signal.aborted) return;
renderMetrics(summary);
if (state.view === 'overview') renderOverview(summary);
else if (state.view === 'servers') renderServers(data);
else if (state.view === 'missions') renderMissions(data);
else renderPlayers(data);
$('updated').textContent = `Refreshed ${date(summary.generatedAt)} · latest stored report ${age(summary.totals.lastReceivedAt)}`;
} catch (error) {
if (run.signal.aborted) return;
$('error').textContent = error.message; $('error').hidden = false; $('updated').textContent = 'Refresh failed. No current data shown.';
$('content').replaceChildren(el('p', 'Could not load records. Use Refresh to try again.', 'empty'));
} finally { if (controller === run) { $('refresh').disabled = false; } }
}
function rawDetails(label, data) { const node = el('details'); node.append(el('summary', label), el('pre', JSON.stringify(data, null, 2))); return node; }
function download(data, name) { const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'})); const a = el('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }
async function showMission(server, mission) {
detailController?.abort(); const run = new AbortController(); detailController = run;
const dialog = $('mission-dialog'); if (!dialog.open) dialog.showModal(); $('mission-body').replaceChildren(el('p', 'Loading mission…')); $('mission-heading').textContent = 'Mission details';
const path = `/api/missions/${encodeURIComponent(server)}/${encodeURIComponent(mission)}`;
try {
const snapshot = await api(path, run.signal); if (run.signal.aborted) return;
const e = snapshot.event, body = $('mission-body'); $('mission-heading').textContent = e.terrain || 'Unknown terrain';
const facts = el('div', null, 'facts');
for (const [label, value] of [['Server', e.serverName?.trim() || snapshot.serverName || server], ['Server ID', server], ['Mission ID', mission], ['Result', labels[e.outcome] || 'Unfinished'], ['Phase', phases[e.phase] || e.phase], ['Duration', duration(e.elapsedSeconds)], ['Snapshot received', date(snapshot.receivedAt)], ['Server-reported time', date(e.occurredAt)], ['Connected / peak players', `${e.connectedPlayers} / ${e.peakPlayers}`], ['Game version', e.gameVersion || 'Unknown'], ['Scenario', e.scenario || 'Unknown'], ['Previous outcome', e.previousOutcome || 'None reported']]) { const item = el('div', null, 'fact'); item.append(el('span', label), el('strong', value)); facts.append(item); }
body.replaceChildren(facts, el('p', e.reason || 'No reason reported.'), el('p', `Coverage: ${e.partial ? 'partial mission' : 'complete mission report'} · ${e.droppedEvents} dropped reports${e.playersTruncated ? ' · players truncated' : ''}${e.modsTruncated ? ' · mods truncated' : ''}`, 'notice'));
if (e.preparationError) body.append(el('p', e.preparationError, 'error'));
body.append(button('Download snapshot JSON', () => download(snapshot, `mission-${mission}.json`), 'secondary'), el('h3', 'Players in this snapshot'));
body.append(table(['Player', 'Connected', 'Participated', 'Escaped', 'Time connected', 'Kills', 'Friendly kills', 'Deaths', 'Downed', 'Recoveries'], e.players.map(p => [stack(p.name || 'Unnamed player', `${p.id}${p.authenticated ? '' : ' · unauthenticated'}`, true), p.connected ? 'Yes' : 'No', p.participated ? 'Yes' : 'No', p.escaped ? 'Yes' : 'No', duration(p.connectedSeconds), ...['kills', 'friendlyKills', 'deaths', 'downed', 'recoveries'].map(k => number(p[k]))])));
const mods = el('details'); mods.append(el('summary', `Mods (${e.mods.length}${e.modsTruncated ? ', truncated' : ''})`), table(['Name', 'Version', 'ID'], e.mods.map(m => [m.name, m.version, m.id]))); body.append(mods);
const settings = el('details'); settings.append(el('summary', `Mission settings (${Object.keys(e.settings).length})`), table(['Setting', 'Value'], Object.entries(e.settings).sort(([a], [b]) => a.localeCompare(b)))); body.append(settings, rawDetails('Complete selected snapshot', snapshot), el('h3', 'Event history'));
const history = el('div'); body.append(history); let before = null; const prior = [];
async function loadEvents() {
history.replaceChildren(el('p', 'Loading events…'));
try {
const data = await api(`${path}/events?limit=25${before ? `&before=${before}` : ''}`, run.signal); if (run.signal.aborted) return;
const rows = data.events.map(row => [timestamp(row.receivedAt), date(row.event.occurredAt), row.event.type, phases[row.event.phase], duration(row.event.elapsedSeconds), rawDetails(row.event.outcome || 'View JSON', row.event)]);
const controls = el('div', null, 'pager'), prev = button('← Newer', () => { before = prior.pop(); loadEvents(); }, 'secondary'), next = button('Older →', () => { prior.push(before); before = data.nextBefore; loadEvents(); }, 'secondary'); prev.disabled = !prior.length; next.disabled = data.nextBefore == null; controls.append(prev, el('span', `Page ${prior.length + 1}`), next);
history.replaceChildren(el('p', 'Newest received first. Queued events can arrive late; the selected final result remains authoritative.', 'small'), table(['Received by collector', 'Reported by server', 'Event', 'Phase', 'Elapsed', 'Payload'], rows), controls);
} catch (error) { if (!run.signal.aborted) history.replaceChildren(el('p', error.message, 'error'), button('Retry events', loadEvents, 'secondary')); }
}
await loadEvents();
} catch (error) { if (!run.signal.aborted) $('mission-body').replaceChildren(el('p', error.message, 'error'), button('Retry mission', () => showMission(server, mission), 'secondary')); }
}
$('login-form').addEventListener('submit', async event => {
event.preventDefault(); const submit = event.target.querySelector('button'); submit.disabled = true; $('login-error').textContent = '';
state.token = $('token').value.trim(); $('token').value = '';
try { await api('/api/dashboard'); $('login').hidden = true; $('app').hidden = false; await refresh(); }
catch (error) { state.token = ''; $('login-error').textContent = error.message; }
finally { submit.disabled = false; }
});
$('logout').addEventListener('click', () => {
controller?.abort(); detailController?.abort(); state.token = ''; state.server = ''; state.serverName = ''; state.view = 'overview'; state.query = ''; state.terrain = ''; state.outcome = ''; resetPages();
$('mission-dialog').close(); $('mission-body').replaceChildren(); $('app').hidden = true; $('login').hidden = false; $('metrics').replaceChildren(); $('content').replaceChildren(); $('token').focus();
});
$('tabs').addEventListener('click', event => { const view = event.target.closest('button')?.dataset.view; if (view) { state.view = view; if (view === 'servers') { state.server = ''; state.serverName = ''; } resetPages(); refresh(); } });
$('refresh').addEventListener('click', refresh);
$('clear-scope').addEventListener('click', () => { state.server = ''; state.serverName = ''; resetPages(); refresh(); });
$('close-dialog').addEventListener('click', () => $('mission-dialog').close());
$('mission-dialog').addEventListener('close', () => detailController?.abort());
}
File diff suppressed because one or more lines are too long
+20 -7
View File
@@ -1,3 +1,5 @@
import {dashboardOverview, dashboardServers} from './dashboard-api.js';
import {dashboardAsset} from './dashboard.js';
import {HttpError, requireValue, object, string, uuid, outcomes, validateEvent, canonical, readJson} from './validation.js';
const json = (value, status = 200) => new Response(JSON.stringify(value), {
@@ -140,12 +142,11 @@ async function ingest(request, env) {
}
async function servers(request, env, url) {
await authorize(request, env);
await authorize(request, env, request.method === 'GET');
if (request.method === 'GET') {
const {limit, before} = paging(url);
const result = await env.DB.prepare(`SELECT rowid AS cursor, server_id AS serverId, name, active, registration, created_at AS createdAt
FROM servers WHERE rowid < ? ORDER BY rowid DESC LIMIT ?`).bind(before, limit).all();
return json({servers: result.results, nextBefore: result.results.length === limit ? result.results.at(-1).cursor : null});
const query = url.searchParams.get('q') ?? '';
requireValue(string(query, 120), 'Invalid server search');
return json(await dashboardServers(env.DB, paging(url), query));
}
method(request, 'POST');
const body = await readJson(request, 4096);
@@ -174,15 +175,20 @@ async function serverCredential(request, env, serverId, rotate) {
async function missions(request, env, url) {
method(request, 'GET'); await authorize(request, env, true);
const {limit, before} = paging(url), serverId = serverFilter(url);
const terrain = url.searchParams.get('terrain');
requireValue(terrain === null || string(terrain, 1024, 1), 'Invalid terrain');
const outcome = url.searchParams.get('outcome');
requireValue(outcome === null || [...outcomes, 'unfinished'].includes(outcome), 'Invalid outcome filter');
const conditions = ['id < ?'], values = [before];
if (serverId) { conditions.push('server_id = ?'); values.push(serverId); }
if (terrain) { conditions.push("lower(json_extract(payload, '$.terrain')) = lower(?)"); values.push(terrain); }
if (outcome) { conditions.push('outcome = ?'); values.push(outcome === 'unfinished' ? '' : outcome); }
const rows = await env.DB.prepare(`SELECT id AS cursor, server_id AS serverId, mission_id AS missionId,
CASE WHEN terminal = 1 THEN outcome ELSE 'unfinished' END AS outcome,
elapsed_seconds AS elapsedSeconds, occurred_at AS occurredAt, received_at AS receivedAt,
partial, peak_players AS peakPlayers, dropped_events AS droppedEvents,
COALESCE(NULLIF(trim(json_extract(payload, '$.serverName')), ''),
(SELECT name FROM servers WHERE servers.server_id = missions.server_id), '') AS serverName,
json_extract(payload, '$.terrain') AS terrain, json_extract(payload, '$.playersTruncated') AS playersTruncated
FROM missions WHERE ${conditions.join(' AND ')} ORDER BY id DESC LIMIT ?`).bind(...values, limit).all();
return json({missions: rows.results, nextBefore: rows.results.length === limit ? rows.results.at(-1).cursor : null});
@@ -198,10 +204,10 @@ async function missionDetails(request, env, serverId, missionId, events, url) {
return json({events: rows.results.map(row => ({cursor: row.id, receivedAt: row.received_at, event: JSON.parse(row.payload)})),
nextBefore: rows.results.length === limit ? rows.results.at(-1).id : null});
}
const row = await env.DB.prepare('SELECT received_at, payload FROM missions WHERE server_id = ? AND mission_id = ?')
const row = await env.DB.prepare('SELECT received_at, payload, (SELECT name FROM servers WHERE servers.server_id = missions.server_id) AS server_name FROM missions WHERE server_id = ? AND mission_id = ?')
.bind(serverId, missionId).first();
if (!row) throw new HttpError(404, 'Mission not found');
return json({receivedAt: row.received_at, event: JSON.parse(row.payload)});
return json({receivedAt: row.received_at, serverName: row.server_name || '', event: JSON.parse(row.payload)});
}
async function stats(request, env, url) {
@@ -240,6 +246,13 @@ export default {
async fetch(request, env) {
try {
const url = new URL(request.url), path = url.pathname;
if (['/dashboard', '/dashboard/', '/dashboard/app.js', '/dashboard/style.css'].includes(path)) {
method(request, 'GET'); return dashboardAsset(path);
}
if (path === '/api/dashboard') {
method(request, 'GET'); await authorize(request, env, true);
return json(await dashboardOverview(env.DB, serverFilter(url)));
}
if (path === '/health') { method(request, 'GET'); return json({ok: true, service: 'co10escape-telemetry'}); }
if (path === '/' || path === '/api/summary' || (path === '/api/mission-events' && request.method === 'GET')) {
return await publicSummary(request, env, url);