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,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/);
});
+97 -1
View File
@@ -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');
});