adds round telemetry support

This commit is contained in:
2026-09-17 14:35:42 -04:00
parent 8e04a190b3
commit 858704bb10
12 changed files with 191 additions and 3 deletions
+28 -1
View File
@@ -97,7 +97,34 @@ reconciliation; this change does not immediately rewrite or restart them.
Servers with an unknown map retain their existing config behavior. Rollback
restores the exact prior config bytes.
## Tests
## Round telemetry experiment
Send `POST /api/telemetry` with `Content-Type: application/json` to the Electric
Cherry host (port 3000 in local development):
```bash
curl http://localhost:3000/api/telemetry \
-H 'Content-Type: application/json' \
-d '{"event":"round_change","round":17,"server":"t5_zombie_prototype_1","players":4}'
```
The server must already be registered in Electric Cherry. `round` and `players`
are required nonnegative integers. Success returns HTTP 200 with
`{ "ok": true, "telemetry": { "server": "...", "round": 17, "players": 4, "receivedAt": "..." } }`.
Invalid payloads return 400, unknown/destroyed servers 404, and non-JSON content
types 415. This endpoint uses the app's existing LAN/Tailscale access model;
there is no API key requirement.
The latest report is saved across app restarts and streamed immediately to the
dashboard and server detail page, including after browser reconnection. The UI
labels it as the last reported round with its age: round-change events alone
cannot detect a stopped script or a match ending. Send the initial round on each
new match, then each round change. The latest received request wins (including
lower rounds for new matches); there is no event ordering or history yet.
Telemetry player counts are displayed with the report and never replace RCON
occupancy or affect automated restart gates.
## Running tests
```bash
npm test
+6
View File
@@ -242,3 +242,9 @@ CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_telemetry (
server TEXT PRIMARY KEY REFERENCES servers(name) ON DELETE CASCADE,
round INTEGER NOT NULL,
players INTEGER NOT NULL,
received_at TEXT NOT NULL
);
+15
View File
@@ -0,0 +1,15 @@
import { db } from '../db/index.js';
import type { RoundTelemetry } from '../../telemetry.js';
export function listTelemetry(): RoundTelemetry[] {
return db().prepare(`SELECT t.server, t.round, t.players, t.received_at AS receivedAt
FROM server_telemetry t JOIN servers s ON s.name = t.server
WHERE s.destroyed_at IS NULL`).all() as RoundTelemetry[];
}
export function saveTelemetry(report: RoundTelemetry): void {
db().prepare(`INSERT INTO server_telemetry (server, round, players, received_at)
VALUES (?, ?, ?, ?) ON CONFLICT(server) DO UPDATE SET
round = excluded.round, players = excluded.players, received_at = excluded.received_at`)
.run(report.server, report.round, report.players, report.receivedAt);
}
+2
View File
@@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events';
import type { RoundTelemetry } from '../../telemetry.js';
import type { RconEndpoint, RconFailure, RconPlayer } from '../integrations/rcon.js';
import type { Health } from '../fleet/health.js';
@@ -36,6 +37,7 @@ export type BetaStage =
export type BetaState = 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
export type PipelineEvent =
| { type: 'server-telemetry'; entries: RoundTelemetry[] }
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
| { type: 'server-changed'; deploymentId: string; server: string; files: number }
| {
+6
View File
@@ -0,0 +1,6 @@
export interface RoundTelemetry {
server: string;
round: number;
players: number;
receivedAt: string;
}
@@ -0,0 +1,19 @@
<script lang="ts">
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
import { elapsedSince } from '$ui/duration.js';
let { name }: { name: string } = $props();
let report = $derived($telemetryByServer.get(name));
</script>
{#if report}
<div class="round" aria-live="polite">
<strong>Round {report.round}</strong>
<span title={report.receivedAt}>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago · {report.players} players</span>
</div>
{/if}
<style>
.round { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); }
strong { color: var(--text-primary); font-family: var(--font-mono); }
span { color: var(--text-tertiary); font-size: var(--text-xs); }
</style>
+3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import RoundTelemetry from './RoundTelemetry.svelte';
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
import { uiNow } from '$ui/stores/servers.js';
import { gametypeLabel } from '$ui/gametype.js';
@@ -118,6 +119,8 @@
{/if}
</div>
<RoundTelemetry {name} />
{#if showTiming}
<div class="timing">
{#if activeElapsed}
+11
View File
@@ -1,5 +1,8 @@
import { writable, readonly } from 'svelte/store';
import { browser } from '$app/environment';
import type { RoundTelemetry } from '$lib/telemetry.js';
export const telemetryByServer = writable<Map<string, RoundTelemetry>>(new Map());
export interface RosterPlayer {
num: number | null;
@@ -49,6 +52,14 @@ let source: EventSource | null = null;
export function startServerStream(): void {
if (!browser || source) return;
source = new EventSource('/api/events/servers');
source.addEventListener('server-telemetry', (evt) => {
const data = JSON.parse((evt as MessageEvent).data) as { entries: RoundTelemetry[]; snapshot?: boolean };
telemetryByServer.update((previous) => {
const next = data.snapshot ? new Map<string, RoundTelemetry>() : new Map(previous);
for (const entry of data.entries) next.set(entry.server, entry);
return next;
});
});
source.addEventListener('server-status', (evt) => {
const data = JSON.parse((evt as MessageEvent).data) as { entries: ServerStatus[] };
statusByServer.update(() => {
+4 -2
View File
@@ -1,4 +1,5 @@
import { subscribe } from '$lib/server/pipeline/bus.js';
import { listTelemetry } from '$lib/server/fleet/telemetry.js';
const encoder = new TextEncoder();
const HEARTBEAT_MS = 20_000;
@@ -10,11 +11,12 @@ export async function GET() {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
unsubscribe = subscribe((event) => {
if (event.type === 'server-status') {
controller.enqueue(encoder.encode(`event: server-status\ndata: ${JSON.stringify(event)}\n\n`));
if (event.type === 'server-status' || event.type === 'server-telemetry') {
controller.enqueue(encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`));
}
});
controller.enqueue(encoder.encode(': hello\n\n'));
controller.enqueue(encoder.encode(`event: server-telemetry\ndata: ${JSON.stringify({ entries: listTelemetry(), snapshot: true })}\n\n`));
heartbeat = setInterval(() => controller.enqueue(encoder.encode(': ping\n\n')), HEARTBEAT_MS);
},
cancel() {
+29
View File
@@ -0,0 +1,29 @@
import { json } from '@sveltejs/kit';
import { getServer } from '$lib/server/fleet/servers.js';
import { saveTelemetry } from '$lib/server/fleet/telemetry.js';
import { emit } from '$lib/server/pipeline/bus.js';
export async function POST({ request }: { request: Request }) {
if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') {
return json({ ok: false, error: 'Content-Type must be application/json' }, { status: 415 });
}
const body = await request.json().catch(() => null);
if (!body || typeof body !== 'object' || Array.isArray(body)
|| body.event !== 'round_change'
|| typeof body.server !== 'string' || !body.server.length || body.server.length > 255
|| !Number.isSafeInteger(body.round) || body.round < 0
|| !Number.isSafeInteger(body.players) || body.players < 0) {
return json({ ok: false, error: 'Expected round_change, a server name, and nonnegative integer round and players' }, { status: 400 });
}
const server = getServer(body.server);
if (!server || server.destroyed_at) {
return json({ ok: false, error: 'Unknown or destroyed server' }, { status: 404 });
}
const telemetry = {
server: body.server as string, round: body.round as number,
players: body.players as number, receivedAt: new Date().toISOString()
};
saveTelemetry(telemetry);
emit({ type: 'server-telemetry', entries: [telemetry] });
return json({ ok: true, telemetry });
}
+3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import RoundTelemetry from '$ui/components/RoundTelemetry.svelte';
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
@@ -304,6 +305,8 @@
{/if}
</div>
<RoundTelemetry {name} />
{#if destroyError}
<div class="destroy-error" role="alert">{destroyError}</div>
{/if}
+65
View File
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture();
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
});
afterEach(async () => fixture.cleanup());
const payload = { event: 'round_change', server: 't5_zombie_theater_1', round: 17, players: 4 };
async function post(body: unknown, contentType = 'application/json') {
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
return POST({ request: new Request('http://localhost/api/telemetry', {
method: 'POST', headers: { 'Content-Type': contentType }, body: JSON.stringify(body)
}) });
}
it('persists reports, broadcasts updates, and leaves RCON safety data untouched', async () => {
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
const listener = vi.fn();
const unsubscribe = subscribe(listener);
expect((await post(payload)).status).toBe(200);
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'server-telemetry' }));
unsubscribe();
// A new match can legitimately report a lower round; retries overwrite one row.
await post({ ...payload, round: 1 });
await post({ ...payload, round: 1 });
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
expect(listTelemetry()).toEqual([expect.objectContaining({ round: 1, players: 4 })]);
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
expect(getServer(payload.server)?.last_players).toBeNull();
const { GET } = await import('../../src/routes/api/events/servers/+server.js');
const response = await GET();
const reader = response.body!.getReader();
await reader.read(); // greeting
const snapshot = await reader.read();
expect(new TextDecoder().decode(snapshot.value)).toContain('"round":1');
await reader.cancel();
});
it.each([null, [], {}, { ...payload, event: 'other' }, { ...payload, round: -1 },
{ ...payload, round: 1.5 }, { ...payload, round: '17' }, { ...payload, players: -1 },
{ ...payload, players: null }])('rejects invalid telemetry: %j', async (body) => {
expect((await post(body)).status).toBe(400);
});
it('rejects unknown and destroyed servers and non-JSON requests', async () => {
expect((await post({ ...payload, server: 'unknown' })).status).toBe(404);
expect((await post(payload, 'text/plain')).status).toBe(415);
const { setServerField } = await import('../../src/lib/server/fleet/servers.js');
setServerField(payload.server, 'destroyed_at', new Date().toISOString());
expect((await post(payload)).status).toBe(404);
});
it('rejects malformed JSON', async () => {
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
expect((await POST({ request: new Request('http://localhost/api/telemetry', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{'
}) })).status).toBe(400);
});