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
+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}