adds round telemetry support
This commit is contained in:
@@ -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
|
Servers with an unknown map retain their existing config behavior. Rollback
|
||||||
restores the exact prior config bytes.
|
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
|
```bash
|
||||||
npm test
|
npm test
|
||||||
|
|||||||
@@ -242,3 +242,9 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
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
|
||||||
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import type { RoundTelemetry } from '../../telemetry.js';
|
||||||
import type { RconEndpoint, RconFailure, RconPlayer } from '../integrations/rcon.js';
|
import type { RconEndpoint, RconFailure, RconPlayer } from '../integrations/rcon.js';
|
||||||
import type { Health } from '../fleet/health.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 BetaState = 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
||||||
|
|
||||||
export type PipelineEvent =
|
export type PipelineEvent =
|
||||||
|
| { type: 'server-telemetry'; entries: RoundTelemetry[] }
|
||||||
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
|
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
|
||||||
| { type: 'server-changed'; deploymentId: string; server: string; files: number }
|
| { type: 'server-changed'; deploymentId: string; server: string; files: number }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import RoundTelemetry from './RoundTelemetry.svelte';
|
||||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||||
import { uiNow } from '$ui/stores/servers.js';
|
import { uiNow } from '$ui/stores/servers.js';
|
||||||
import { gametypeLabel } from '$ui/gametype.js';
|
import { gametypeLabel } from '$ui/gametype.js';
|
||||||
@@ -118,6 +119,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RoundTelemetry {name} />
|
||||||
|
|
||||||
{#if showTiming}
|
{#if showTiming}
|
||||||
<div class="timing">
|
<div class="timing">
|
||||||
{#if activeElapsed}
|
{#if activeElapsed}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { writable, readonly } from 'svelte/store';
|
import { writable, readonly } from 'svelte/store';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import type { RoundTelemetry } from '$lib/telemetry.js';
|
||||||
|
|
||||||
|
export const telemetryByServer = writable<Map<string, RoundTelemetry>>(new Map());
|
||||||
|
|
||||||
export interface RosterPlayer {
|
export interface RosterPlayer {
|
||||||
num: number | null;
|
num: number | null;
|
||||||
@@ -49,6 +52,14 @@ let source: EventSource | null = null;
|
|||||||
export function startServerStream(): void {
|
export function startServerStream(): void {
|
||||||
if (!browser || source) return;
|
if (!browser || source) return;
|
||||||
source = new EventSource('/api/events/servers');
|
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) => {
|
source.addEventListener('server-status', (evt) => {
|
||||||
const data = JSON.parse((evt as MessageEvent).data) as { entries: ServerStatus[] };
|
const data = JSON.parse((evt as MessageEvent).data) as { entries: ServerStatus[] };
|
||||||
statusByServer.update(() => {
|
statusByServer.update(() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { subscribe } from '$lib/server/pipeline/bus.js';
|
import { subscribe } from '$lib/server/pipeline/bus.js';
|
||||||
|
import { listTelemetry } from '$lib/server/fleet/telemetry.js';
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const HEARTBEAT_MS = 20_000;
|
const HEARTBEAT_MS = 20_000;
|
||||||
@@ -10,11 +11,12 @@ export async function GET() {
|
|||||||
const stream = new ReadableStream<Uint8Array>({
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
unsubscribe = subscribe((event) => {
|
unsubscribe = subscribe((event) => {
|
||||||
if (event.type === 'server-status') {
|
if (event.type === 'server-status' || event.type === 'server-telemetry') {
|
||||||
controller.enqueue(encoder.encode(`event: server-status\ndata: ${JSON.stringify(event)}\n\n`));
|
controller.enqueue(encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
controller.enqueue(encoder.encode(': hello\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);
|
heartbeat = setInterval(() => controller.enqueue(encoder.encode(': ping\n\n')), HEARTBEAT_MS);
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import RoundTelemetry from '$ui/components/RoundTelemetry.svelte';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -304,6 +305,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RoundTelemetry {name} />
|
||||||
|
|
||||||
{#if destroyError}
|
{#if destroyError}
|
||||||
<div class="destroy-error" role="alert">{destroyError}</div>
|
<div class="destroy-error" role="alert">{destroyError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user