Add telemetry dashboard and automatic server names; record Workshop 0.0.137
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -69,13 +69,57 @@ set, administration and detailed reads fail closed. Public aggregate summaries
|
||||
remain available. Public ingestion enrolls servers
|
||||
independently of this admin credential. Wrangler prints the Worker URL, for example
|
||||
`https://co10escape-telemetry.YOUR-SUBDOMAIN.workers.dev`. `/health` is a public liveness
|
||||
check, not a database readiness test. The service does not enable CORS; a future
|
||||
browser dashboard should use a backend to keep API credentials private.
|
||||
check, not a database readiness test. The service does not enable CORS. The dashboard is served from the same Worker
|
||||
and uses your entered read credential without embedding any service secret.
|
||||
|
||||
Deployment uses Cloudflare's documented [D1 migrations](https://developers.cloudflare.com/d1/reference/migrations/),
|
||||
[Worker secrets](https://developers.cloudflare.com/workers/configuration/secrets/),
|
||||
and [transactional D1 batches](https://developers.cloudflare.com/d1/worker-api/d1-database/#batch).
|
||||
|
||||
## Statistics dashboard
|
||||
|
||||
Open `https://co10escape-telemetry.zacekjakub.workers.dev/dashboard` after deploying
|
||||
this Worker. Enter the existing `READ_TOKEN` (recommended) or `ADMIN_TOKEN`.
|
||||
The static login page is public; every detailed data request is authenticated.
|
||||
Tokens remain in tab memory, never in URLs, browser storage or bundled assets.
|
||||
Refreshing the page or signing out clears the token. There are no third-party
|
||||
scripts, fonts or analytics and no server-administration buttons.
|
||||
|
||||
- **Overview:** total servers/reports/missions, wins, losses, win rate, outcome
|
||||
breakdown and terrain records. Counts cover all stored records, not just the page.
|
||||
- **Servers:** per-installation mission totals, last stored report and its event
|
||||
type, latest reported terrain/player count, registration and ingestion status.
|
||||
Search by saved name or installation ID. Unnamed servers retain their UUID.
|
||||
Updated missions automatically report the configured game-server name; automatic
|
||||
installation labels follow the newest nonblank reported name. Blank/older queued
|
||||
reports cannot erase it. Explicitly managed registration labels remain unchanged.
|
||||
- **Missions:** page through history, filter by server/outcome/exact terrain,
|
||||
and open players, mods, numeric settings, coverage flags, selected snapshot JSON
|
||||
and paginated raw event history. Download the selected snapshot as JSON.
|
||||
- **Players:** personal escapes and combat totals, including counter coverage.
|
||||
Coverage counts are not counts of missions with friendly-fire incidents.
|
||||
|
||||
Selecting a server scopes Overview, Missions and Players. The Servers tab returns
|
||||
to all installations. Overview shows at most 100 terrains with an explicit notice
|
||||
if truncated; all missions remain accessible through filters and pagination.
|
||||
Lists show 25 records per page. Automatic refresh is disabled. Use **Refresh**
|
||||
to update the current view; navigation and filters fetch their requested records.
|
||||
|
||||
**Last received** is the collector's timestamp of the latest newly stored event.
|
||||
It includes late queued events that do not replace a completed mission snapshot;
|
||||
idempotent retries do not advance it. The separately shown server-reported time
|
||||
may have clock skew. Game heartbeats normally arrive every five minutes. Neither
|
||||
fresh receipts nor silence prove online/offline status. A server's Enabled/Revoked
|
||||
label controls ingestion and is not an online indicator. No-report servers show
|
||||
“No reports yet”; absent counters and undefined win rates show a dash.
|
||||
|
||||
Run `npm run migrate:remote` before `npm run deploy`. Migration 0003 adds a
|
||||
receipt lookup index; 0004 maintains automatic server labels from accepted events
|
||||
and recovers any existing reported names without rewriting event history.
|
||||
The webpage requires no mission update. Automatic name discovery requires the
|
||||
updated mission release; no operator configuration is needed. Until such a server
|
||||
reports, blank historical names cannot be reconstructed from its installation UUID.
|
||||
|
||||
## Automatic community-server enrollment
|
||||
|
||||
The updated addon saves a random server ID in its telemetry state and generates
|
||||
@@ -179,18 +223,20 @@ they are not invented as zero. This feature requires the updated Workshop build.
|
||||
|
||||
## API
|
||||
|
||||
All responses are JSON with `Cache-Control: no-store`. Administrative endpoints
|
||||
API responses are JSON with `Cache-Control: no-store`. Administrative endpoints
|
||||
require `Authorization: Bearer ADMIN_TOKEN`. Read endpoints accept ADMIN_TOKEN or
|
||||
the optional READ_TOKEN. Public summaries and `/health` do not require a token;
|
||||
ingestion uses each installation's own credential.
|
||||
|
||||
| Method and path | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /dashboard` | Browser statistics dashboard; details require a read/admin token |
|
||||
| `GET /api/dashboard` | Authenticated overview, optional `serverId`; totals, outcomes and up to 100 terrains |
|
||||
| `GET /health` | Public liveness check |
|
||||
| `GET /`, `GET /api/summary`, `GET /api/mission-events` | Public map win/failure summaries; optional `terrain` and `serverId` filters |
|
||||
| `POST /api/mission-events` | Ingest `{ "token": "...", "event": {...} }`; first valid event enrolls an unknown server |
|
||||
| `POST /api/servers` | Register `{serverId, name}` and return a new token |
|
||||
| `GET /api/servers` | List server labels, IDs, registration origin, creation times and active state; never token hashes |
|
||||
| `GET /api/servers` | Read/admin access: labels, IDs, registration, mission totals, last received report and active ingestion state; optional `q` name/ID search; never token hashes |
|
||||
| `POST /api/servers/:serverId/token` | Rotate token and reactivate server |
|
||||
| `DELETE /api/servers/:serverId` | Revoke ingestion; retain history |
|
||||
| `GET /api/missions` | Mission summaries, newest-created first |
|
||||
@@ -200,7 +246,7 @@ ingestion uses each installation's own credential.
|
||||
| `GET /api/players` | Authenticated participants' attempts, personal escapes and combat totals |
|
||||
|
||||
`/api/missions`, `/api/stats` and `/api/players` accept an optional `serverId` filter.
|
||||
`/api/missions` also accepts `outcome` (`victory`, `defeat`, `preparation_failed`,
|
||||
`/api/missions` also accepts an exact, case-insensitive `terrain` filter and `outcome` (`victory`, `defeat`, `preparation_failed`,
|
||||
`restarted_empty`, `interrupted`, or `unfinished`). Mission, server and event lists
|
||||
accept `limit` (1–100, default 50) and `before` from the preceding `nextBefore`.
|
||||
Player lists sort by stable account ID and use `after` from `nextAfter`, plus
|
||||
@@ -251,3 +297,19 @@ policy should preserve deduplication tombstones if raw payloads are removed.
|
||||
Keep read credentials private: mission details contain player names and account
|
||||
IDs. Worker request logging is disabled in the supplied configuration. Cloudflare
|
||||
platform logging/settings are managed separately in the account.
|
||||
|
||||
## Dashboard verification (2026-11-01)
|
||||
|
||||
20 local tests pass, including real Miniflare/D1 ingestion and aggregate queries,
|
||||
read-only authorization, late receipt/final-result separation, pagination and
|
||||
client auth/navigation/detail/error behavior using a minimal DOM boundary.
|
||||
`npm run check` also exercises the exact Wrangler-bundled browser script; keep
|
||||
`keep_names: false` so function serialization has no Worker-only helper references.
|
||||
|
||||
Migration 0003 was applied and Worker version
|
||||
`8fa09cd0-f148-43d4-a6d8-685528d73618` deployed. Live dashboard HTML/JS/CSS returned
|
||||
200 and matched the tested bundle byte-for-byte; health/public summary returned
|
||||
200 and unauthenticated detailed routes returned 401. No production test events
|
||||
were submitted. Native browser visual/accessibility verification was unavailable
|
||||
in this session; the DOM tests do not establish layout quality. Authenticated
|
||||
production data rendering was not exercised with the operator's private token.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Bounded per-server lookup of the last stored report, including late events
|
||||
-- that did not change the authoritative mission snapshot.
|
||||
CREATE INDEX events_server_receipt ON events(server_id, received_at DESC, id DESC);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Names describe an installation; its existing UUID remains the identity.
|
||||
-- Operator-managed labels remain under operator control.
|
||||
ALTER TABLE servers ADD COLUMN name_reported_at INTEGER;
|
||||
|
||||
UPDATE servers SET
|
||||
name = (SELECT trim(json_extract(payload, '$.serverName')) FROM events
|
||||
WHERE events.server_id = servers.server_id AND trim(json_extract(payload, '$.serverName')) != ''
|
||||
ORDER BY json_extract(payload, '$.occurredAt') DESC, id DESC LIMIT 1),
|
||||
name_reported_at = (SELECT json_extract(payload, '$.occurredAt') FROM events
|
||||
WHERE events.server_id = servers.server_id AND trim(json_extract(payload, '$.serverName')) != ''
|
||||
ORDER BY json_extract(payload, '$.occurredAt') DESC, id DESC LIMIT 1)
|
||||
WHERE registration = 'automatic' AND EXISTS (
|
||||
SELECT 1 FROM events WHERE events.server_id = servers.server_id
|
||||
AND trim(json_extract(payload, '$.serverName')) != '');
|
||||
|
||||
-- This runs only for a newly accepted, authenticated event in its transaction.
|
||||
-- Blank reports and old queued events must not erase/roll back a known name.
|
||||
CREATE TRIGGER project_server_name AFTER INSERT ON events
|
||||
WHEN trim(json_extract(NEW.payload, '$.serverName')) != '' BEGIN
|
||||
UPDATE servers SET name = trim(json_extract(NEW.payload, '$.serverName')),
|
||||
name_reported_at = json_extract(NEW.payload, '$.occurredAt')
|
||||
WHERE server_id = NEW.server_id AND registration = 'automatic'
|
||||
AND (name_reported_at IS NULL OR json_extract(NEW.payload, '$.occurredAt') >= name_reported_at);
|
||||
END;
|
||||
@@ -9,7 +9,7 @@
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js",
|
||||
"dev": "wrangler dev",
|
||||
"check": "wrangler deploy --dry-run --outdir dist",
|
||||
"check": "wrangler deploy --dry-run --outdir dist && DASHBOARD_BUNDLE=1 node --test test/dashboard-client.test.js",
|
||||
"deploy": "wrangler deploy",
|
||||
"migrate:local": "wrangler d1 migrations apply DB --local",
|
||||
"migrate:remote": "wrangler d1 migrations apply DB --remote"
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {runInNewContext} from 'node:vm';
|
||||
import {dashboardApp} from '../src/dashboard-client.js';
|
||||
const clientScript = process.env.DASHBOARD_BUNDLE
|
||||
? await (await (await import('../dist/index.js')).default.fetch(new Request('https://telemetry.example/dashboard/app.js'), {})).text()
|
||||
: `(${dashboardApp.toString()})();`;
|
||||
|
||||
// Minimal DOM boundary for client behavior; not a browser layout test.
|
||||
class Element {
|
||||
constructor(tag = 'div') { this.tagName=tag.toUpperCase(); this.children=[]; this.listeners={}; this.dataset={}; this.attributes={}; this.hidden=false; this.value=''; this.open=false; this._text=''; }
|
||||
set textContent(value) { this._text=String(value); this.children=[]; }
|
||||
get textContent() { return this._text+this.children.map(x=>x.textContent).join(' '); }
|
||||
set innerHTML(_) { throw new Error('HTML interpolation forbidden'); }
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
replaceChildren(...nodes) { this._text=''; this.children=nodes; }
|
||||
addEventListener(type, handler) { this.listeners[type]=handler; }
|
||||
setAttribute(key,value) { this.attributes[key]=value; }
|
||||
removeAttribute(key) { delete this.attributes[key]; }
|
||||
closest(selector) { return selector==='button'&&this.tagName==='BUTTON'?this:null; }
|
||||
querySelector(selector) { return descendants(this).find(x=>x.tagName.toLowerCase()===selector)||null; }
|
||||
showModal() { this.open=true; }
|
||||
close() { this.open=false; this.listeners.close?.(); }
|
||||
focus() {}
|
||||
}
|
||||
const descendants = node => node.children.flatMap(child=>[child,...descendants(child)]);
|
||||
const tick = async () => { for(let i=0;i<8;i++) await new Promise(resolve=>setImmediate(resolve)); };
|
||||
function fixture() {
|
||||
const ids=Object.fromEntries(['token','login','login-form','login-error','app','metrics','content','updated','error','scope-label','clear-scope','tabs','refresh','logout','mission-dialog','mission-heading','mission-body','close-dialog'].map(id=>[id,new Element()]));
|
||||
ids['login-form'].append(new Element('button')); ids.app.hidden=true;
|
||||
for(const view of ['overview','servers','missions','players']) { const node=new Element('button'); node.dataset.view=view; ids.tabs.append(node); }
|
||||
const sid='11111111-1111-4111-8111-111111111111',mid='22222222-2222-4222-8222-222222222222',malicious='<img src=x onerror=alert(1)>';
|
||||
const summary={generatedAt:1793361000,totals:{servers:1,missions:1,events:3,wins:1,losses:0,unfinished:0,partialMissions:0,winRate:100,lastReceivedAt:1793361000},outcomes:[{outcome:'victory',missions:1}],maps:[{terrain:'Everon',missions:1,wins:1,losses:0,unfinished:0}]};
|
||||
const event={serverId:sid,missionId:mid,serverName:malicious,terrain:'Everon',outcome:'victory',phase:5,elapsedSeconds:120,occurredAt:1793360000,connectedPlayers:1,peakPlayers:1,gameVersion:'1.6',scenario:'Escape',previousOutcome:'',reason:malicious,partial:false,droppedEvents:0,mods:[{name:malicious,version:'1',id:'mod'}],settings:{roadblockCount:4},players:[{id:sid,name:malicious,authenticated:true,connected:true,participated:true,escaped:true,connectedSeconds:120,kills:2,friendlyKills:0}]};
|
||||
const requests=[]; let fail=false;
|
||||
const document={getElementById:id=>ids[id],createElement:tag=>new Element(tag),createTextNode:text=>{const node=new Element('#text');node.textContent=text;return node;},querySelectorAll:()=>ids.tabs.children,hidden:false};
|
||||
const fetch=async(path,options)=>{
|
||||
requests.push({path,options});
|
||||
if(fail)return{ok:false,status:503,json:async()=>({error:'Temporarily unavailable'})};
|
||||
if(options.headers.Authorization!=='Bearer valid-read-token-0123456789abcdef')return{ok:false,status:401,json:async()=>({})};
|
||||
let data;
|
||||
if(path.startsWith('/api/dashboard'))data=summary;
|
||||
else if(path.startsWith('/api/servers'))data={servers:[{serverId:sid,name:malicious,wins:1,losses:0,missions:1,unfinished:0,lastReceivedAt:1793361000,lastEventType:'heartbeat',lastReportedPlayers:1,lastMissionId:mid,terrain:'Everon',active:1,registration:'automatic'}],nextBefore:7};
|
||||
else if(path.includes('/events?'))data={events:[{receivedAt:1793361000,event}],nextBefore:null};
|
||||
else if(path.startsWith(`/api/missions/${sid}/`))data={receivedAt:1793361000,event};
|
||||
else if(path.startsWith('/api/missions?'))data={missions:[{...event,receivedAt:1793361000}],nextBefore:null};
|
||||
else if(path.startsWith('/api/players'))data={players:[{playerId:sid,attempts:1,escapes:1,didNotEscape:0,bestEscapeSeconds:120,kills:2,missionsWithKills:1,friendlyKills:0,missionsWithFriendlyKills:1}],nextAfter:null};
|
||||
else throw new Error('Unexpected path '+path);
|
||||
return{ok:true,status:200,json:async()=>data};
|
||||
};
|
||||
runInNewContext(clientScript,{document,Node:Element,fetch,AbortController,URLSearchParams,Date,setInterval:()=>{throw new Error("Automatic refresh must remain disabled");},setTimeout,URL,Blob});
|
||||
const click=async(node,target=node)=>{node.listeners.click?.({target});await tick();};
|
||||
const button=(root,text)=>{const node=descendants(root).find(x=>x.tagName==='BUTTON'&&x.textContent===text);assert.ok(node,`Button ${text}`);return node;};
|
||||
const login=async token=>{ids.token.value=token;await ids['login-form'].listeners.submit({preventDefault(){},target:ids['login-form']});await tick();};
|
||||
return{ids,requests,sid,malicious,click,button,login,tab:async view=>click(ids.tabs,ids.tabs.children.find(x=>x.dataset.view===view)),fail:()=>{fail=true;}};
|
||||
}
|
||||
|
||||
test('client login, scope, pagination, mission details, safe text and logout',async()=>{
|
||||
const f=fixture();await f.login('wrong-token');assert.match(f.ids['login-error'].textContent,/Token rejected/);assert.equal(f.ids.app.hidden,true);
|
||||
await f.login('valid-read-token-0123456789abcdef');assert.equal(f.ids.app.hidden,false);assert.equal(f.ids.token.value,'');assert.match(f.ids.metrics.textContent,/100%/);
|
||||
await f.tab('servers');assert.match(f.ids.content.textContent,/Last received/);assert.ok(f.ids.content.textContent.includes(f.malicious));assert.equal(descendants(f.ids.content).some(x=>x.tagName==='IMG'),false);
|
||||
await f.click(f.button(f.ids.content,'Next →'));assert.ok(f.requests.some(r=>r.path.includes('before=7')));
|
||||
await f.click(f.button(f.ids.content,f.malicious));assert.ok(f.requests.some(r=>r.path.startsWith('/api/missions?')&&r.path.includes(`serverId=${f.sid}`)));
|
||||
await f.click(f.button(f.ids.content,'Everon'));assert.equal(f.ids['mission-dialog'].open,true);assert.match(f.ids['mission-body'].textContent,/Mission settings/);assert.match(f.ids['mission-body'].textContent,/Event history/);assert.ok(f.ids['mission-body'].textContent.includes(f.malicious));
|
||||
await f.click(f.ids['close-dialog']);assert.equal(f.ids['mission-dialog'].open,false);
|
||||
await f.tab('players');assert.match(f.ids.content.textContent,/1\/1 missions reported this counter/);
|
||||
await f.click(f.ids.logout);assert.equal(f.ids.app.hidden,true);assert.equal(f.ids.content.textContent,'');assert.equal(f.ids['mission-body'].textContent,'');
|
||||
for(const request of f.requests)assert.ok(!request.path.includes('token'));
|
||||
});
|
||||
test('failed refresh removes stale records and explains retry',async()=>{
|
||||
const f=fixture();await f.login('valid-read-token-0123456789abcdef');f.fail();await f.click(f.ids.refresh);
|
||||
assert.equal(f.ids.error.hidden,false);assert.match(f.ids.updated.textContent,/Refresh failed/);assert.equal(f.ids.metrics.textContent,'');assert.match(f.ids.content.textContent,/Use Refresh/);
|
||||
});
|
||||
@@ -41,7 +41,7 @@ async function detail(e) {
|
||||
}
|
||||
before(async () => {
|
||||
mf = new Miniflare(convertV4MiniflareOptions({host: '127.0.0.1', port: 0, workers: [{name: 'telemetry',
|
||||
modules: ['index.js', 'validation.js'].map(name => ({type: 'ESModule', path: new URL(`../src/${name}`, import.meta.url).pathname})),
|
||||
modules: ['index.js', 'validation.js', 'dashboard-api.js', 'dashboard.js', 'dashboard-client.js'].map(name => ({type: 'ESModule', path: new URL(`../src/${name}`, import.meta.url).pathname})),
|
||||
compatibilityDate: '2026-10-23', d1Databases: ['DB'],
|
||||
ratelimits: {
|
||||
REQUEST_LIMITER: {namespace_id: '1001', simple: {limit: 300, period: 60}},
|
||||
@@ -302,3 +302,99 @@ test('map records count victories and defeats across servers, excluding other ma
|
||||
const row = maps.maps.find(m => m.terrain === terrain.toLowerCase());
|
||||
assert.deepEqual(row, {terrain: terrain.toLowerCase(), completed: 2, wins: 1, losses: 1});
|
||||
});
|
||||
|
||||
test('dashboard shell is public, same-origin and contains no telemetry or credentials', async () => {
|
||||
for (const path of ['/dashboard', '/dashboard/', '/dashboard/app.js', '/dashboard/style.css']) {
|
||||
const response = await call(path);
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get('content-security-policy'), /frame-ancestors 'none'/);
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
const content = await response.text();
|
||||
assert.ok(!content.includes(ADMIN) && !content.includes(READ) && !content.includes(token));
|
||||
}
|
||||
assert.equal((await call('/dashboard', {method: 'POST'})).status, 405);
|
||||
assert.equal((await call('/api/dashboard')).status, 401);
|
||||
assert.equal((await call('/api/servers')).status, 401);
|
||||
assert.equal((await call('/api/dashboard', {headers: {authorization: `Bearer ${token}`}})).status, 401);
|
||||
assert.equal((await call('/api/servers', {headers: readAuth})).status, 200);
|
||||
assert.equal((await call(`/api/servers/${serverId}`, {method: 'DELETE', headers: readAuth})).status, 401);
|
||||
});
|
||||
|
||||
test('dashboard receipt freshness uses raw arrivals, independently of terminal snapshot selection', async () => {
|
||||
const sid = crypto.randomUUID(), writeToken = 'dashboard-installation-secret-0123456789';
|
||||
const complete = event({serverId: sid, serverName: '<img src=x onerror=alert(1)>', terrain: 'Everon dashboard', players: []});
|
||||
assert.equal((await ingest(complete, writeToken)).status, 201);
|
||||
const finalReceipt = (await db.prepare('SELECT received_at FROM missions WHERE server_id = ?').bind(sid).first()).received_at;
|
||||
const delayed = {...complete, eventId: crypto.randomUUID(), type: 'heartbeat', phase: 1, outcome: '', occurredAt: 1, elapsedSeconds: 10};
|
||||
assert.equal((await ingest(delayed, writeToken)).status, 201);
|
||||
await db.prepare('UPDATE events SET received_at = ? WHERE server_id = ? AND event_id = ?').bind(finalReceipt + 40, sid, delayed.eventId).run();
|
||||
const rows = await (await call(`/api/servers?q=${sid}`, {headers: readAuth})).json();
|
||||
assert.equal(rows.servers.length, 1);
|
||||
const server = rows.servers[0];
|
||||
assert.equal(server.lastReceivedAt, finalReceipt + 40);
|
||||
assert.equal(server.lastReportedAt, 1);
|
||||
assert.equal(server.lastEventType, 'heartbeat');
|
||||
assert.equal(server.wins, 1); assert.equal(server.losses, 0); assert.equal(server.unfinished, 0);
|
||||
assert.equal(server.lastMissionId, complete.missionId);
|
||||
assert.equal(server.name, complete.serverName); // Stored literally; browser renders as text.
|
||||
assert.ok(!JSON.stringify(rows).includes('token_hash'));
|
||||
const snapshot = await (await call(`/api/missions/${sid}/${complete.missionId}`, {headers: readAuth})).json();
|
||||
assert.equal(snapshot.receivedAt, finalReceipt); assert.equal(snapshot.event.outcome, 'victory');
|
||||
const summary = await (await call(`/api/dashboard?serverId=${sid}`, {headers: readAuth})).json();
|
||||
assert.equal(summary.totals.missions, 1); assert.equal(summary.totals.events, 2);
|
||||
assert.equal(summary.totals.wins, 1); assert.equal(summary.totals.winRate, 100);
|
||||
assert.equal(summary.totals.servers, 1); assert.equal(summary.totals.lastReceivedAt, finalReceipt + 40);
|
||||
assert.deepEqual(summary.maps.map(m => m.terrain), ['everon dashboard']);
|
||||
assert.equal((await ingest(delayed, writeToken)).status, 200);
|
||||
const duplicateSummary = await (await call(`/api/dashboard?serverId=${sid}`, {headers: readAuth})).json();
|
||||
assert.equal(duplicateSummary.totals.events, 2);
|
||||
const empty = await (await call(`/api/dashboard?serverId=${crypto.randomUUID()}`, {headers: readAuth})).json();
|
||||
assert.equal(empty.totals.lastReceivedAt, null); assert.equal(empty.totals.winRate, null);
|
||||
assert.equal(empty.totals.missions, 0); assert.deepEqual(empty.maps, []);
|
||||
});
|
||||
|
||||
test('dashboard server pagination, never-reporting servers and exact terrain filters', async () => {
|
||||
const sid = crypto.randomUUID();
|
||||
assert.equal((await post('/api/servers', {serverId: sid, name: 'Never reported'}, auth)).status, 201);
|
||||
const first = await (await call('/api/servers?limit=1', {headers: readAuth})).json();
|
||||
assert.equal(first.servers[0].serverId, sid);
|
||||
assert.equal(first.servers[0].lastReceivedAt, null); assert.equal(first.servers[0].missions, 0);
|
||||
const next = await (await call(`/api/servers?limit=1&before=${first.nextBefore}`, {headers: readAuth})).json();
|
||||
assert.notEqual(next.servers[0].serverId, sid);
|
||||
const matched = await (await call('/api/missions?terrain=EVERON%20DASHBOARD', {headers: readAuth})).json();
|
||||
assert.equal(matched.missions.length, 1); assert.equal(matched.missions[0].outcome, 'victory');
|
||||
const empty = await (await call('/api/missions?terrain=Missing%20island', {headers: readAuth})).json();
|
||||
assert.equal(empty.missions.length, 0);
|
||||
assert.equal((await call('/api/servers?q=' + 'a'.repeat(121), {headers: readAuth})).status, 400);
|
||||
assert.equal((await call('/api/dashboard?serverId=bad', {headers: readAuth})).status, 400);
|
||||
});
|
||||
|
||||
test('automatic server names arrive later, follow renames, and survive blanks, retries and old queued reports', async () => {
|
||||
const sid = crypto.randomUUID(), secret = 'server-name-test-credential-0123456789';
|
||||
const original = event({serverId: sid, serverName: '', players: [], occurredAt: 100});
|
||||
assert.equal((await ingest(original, secret)).status, 201);
|
||||
const name = async () => (await db.prepare('SELECT name FROM servers WHERE server_id = ?').bind(sid).first()).name;
|
||||
assert.equal(await name(), '');
|
||||
const report = (serverName, occurredAt) => event({serverId: sid, serverName, occurredAt, players: []});
|
||||
const first = report(' Community Escape ', 200);
|
||||
assert.equal((await ingest(first, secret)).status, 201); assert.equal(await name(), 'Community Escape');
|
||||
const renamed = report('New name', 300);
|
||||
assert.equal((await ingest(renamed, secret)).status, 201); assert.equal(await name(), 'New name');
|
||||
assert.equal((await ingest(report(' ', 400), secret)).status, 201); assert.equal(await name(), 'New name');
|
||||
assert.equal((await ingest(report('Old name', 150), secret)).status, 201); assert.equal(await name(), 'New name');
|
||||
assert.equal((await ingest(first, secret)).status, 200); assert.equal(await name(), 'New name');
|
||||
assert.equal((await ingest({...renamed, serverName: 'Conflict'}, secret)).status, 409); assert.equal(await name(), 'New name');
|
||||
assert.equal((await ingest(report('Impersonated', 500), 'wrong-credential-0123456789abcdef123456')).status, 401);
|
||||
assert.equal(await name(), 'New name');
|
||||
const listing = await (await call(`/api/servers?q=${encodeURIComponent('New name')}`, {headers: readAuth})).json();
|
||||
assert.equal(listing.servers[0].serverId, sid);
|
||||
const missions = await (await call(`/api/missions?serverId=${sid}`, {headers: readAuth})).json();
|
||||
assert.equal(missions.missions.find(m => m.missionId === original.missionId).serverName, 'New name');
|
||||
assert.equal(missions.missions.find(m => m.missionId === first.missionId).serverName, 'Community Escape');
|
||||
const snapshot = await (await call(`/api/missions/${sid}/${original.missionId}`, {headers: readAuth})).json();
|
||||
assert.equal(snapshot.serverName, 'New name'); assert.equal(snapshot.event.serverName, ''); // No historical payload rewriting.
|
||||
const managed = crypto.randomUUID();
|
||||
const registered = await (await post('/api/servers', {serverId: managed, name: 'Operator label'}, auth)).json();
|
||||
assert.equal((await ingest(event({serverId: managed, serverName: 'Game name', players: []}), registered.token)).status, 201);
|
||||
assert.equal((await db.prepare('SELECT name FROM servers WHERE server_id = ?').bind(managed).first()).name, 'Operator label');
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "co10escape-telemetry",
|
||||
"main": "src/index.js",
|
||||
// The dashboard serializes its self-contained browser function. Avoid
|
||||
// injecting references to Worker-only function-name helpers into that code.
|
||||
"keep_names": false,
|
||||
"compatibility_date": "2026-10-23",
|
||||
"workers_dev": true,
|
||||
"d1_databases": [{
|
||||
|
||||
Reference in New Issue
Block a user