From 9bff47a9a470c12203af616a21336671869d37c3 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 12 Sep 2026 08:20:54 -0400 Subject: [PATCH] Perk-a-cola redesign + surface RCON map/gametype/roster/health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Themes the fleet manager around the Electric Cherry perk-a-cola from Mob of the Dead — cherry-red art-deco label with electric cyan discharge. New design tokens (Alfa Slab One + Inter + JetBrains Mono, self-hosted), full redesign of layout/dashboard/cards/detail page, tokens pass on deployments/betas/pipeline/beta/diff, cherry+lightning favicon. Lights up server data that RCON was already fetching but the UI dropped. parsePlayerRoster() extracts num/score/ping/guid/name from both status dialects. Worker persists last_map/last_gametype/last_roster/last_seen_at/ last_error (new columns with idempotent boot-time ALTERs) and emits them over SSE. Fleet cards now show health dot, map · gametype, top-3 roster preview, and last-seen. Detail page adds a sortable full roster with distinct empty-lobby vs offline states. New health classifier (online/stale/offline) drives the visual state. Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 30 ++ package.json | 3 + src/app.html | 2 +- src/lib/server/db/index.ts | 23 ++ src/lib/server/db/schema.sql | 5 + src/lib/server/fleet/health.ts | 22 ++ src/lib/server/fleet/servers.ts | 12 +- src/lib/server/integrations/rcon.ts | 59 +++- src/lib/server/pipeline/bus.ts | 15 +- src/lib/server/pipeline/worker.ts | 57 +++- src/lib/ui/components/BetaPanel.svelte | 164 +++++++--- src/lib/ui/components/BrandMark.svelte | 37 +++ src/lib/ui/components/DiffViewer.svelte | 67 ++-- src/lib/ui/components/PipelineTimeline.svelte | 100 +++--- src/lib/ui/components/PlayerRoster.svelte | 111 +++++++ src/lib/ui/components/ServerCard.svelte | 263 +++++++++++++--- src/lib/ui/components/StatusDot.svelte | 37 +++ src/lib/ui/gametype.ts | 21 ++ src/lib/ui/stores/servers.ts | 17 +- src/lib/ui/styles/tokens.css | 78 +++++ src/routes/+layout.svelte | 94 ++++-- src/routes/+page.svelte | 124 ++++++-- src/routes/api/servers/+server.ts | 13 + src/routes/betas/+page.svelte | 14 +- src/routes/deployments/+page.svelte | 98 ++++-- src/routes/deployments/[id]/+page.svelte | 162 ++++++---- src/routes/servers/[name]/+page.svelte | 289 ++++++++++++++---- static/favicon.svg | 17 ++ tests/unit/rcon.test.ts | 58 +++- 29 files changed, 1636 insertions(+), 356 deletions(-) create mode 100644 src/lib/server/fleet/health.ts create mode 100644 src/lib/ui/components/BrandMark.svelte create mode 100644 src/lib/ui/components/PlayerRoster.svelte create mode 100644 src/lib/ui/components/StatusDot.svelte create mode 100644 src/lib/ui/gametype.ts create mode 100644 src/lib/ui/styles/tokens.css create mode 100644 static/favicon.svg diff --git a/package-lock.json b/package-lock.json index 9946754..c96cb6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "electric-cherry", "version": "0.1.0", "dependencies": { + "@fontsource/alfa-slab-one": "^5.3.0", + "@fontsource/inter": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "better-sqlite3": "^11.3.0" }, "devDependencies": { @@ -466,6 +469,33 @@ "node": ">=18" } }, + "node_modules/@fontsource/alfa-slab-one": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/alfa-slab-one/-/alfa-slab-one-5.3.0.tgz", + "integrity": "sha512-J/ANFZ7S9CbZClr/QhMjmBtpFmmKipVkEXHj20aurR5uqJPCBChVPJn1/kx6vtrwnfnDY0kQaYSTYasSt4GruA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/package.json b/package.json index b23c7ec..8549a92 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "seed:fixture": "tsx scripts/seed-fixture.ts" }, "dependencies": { + "@fontsource/alfa-slab-one": "^5.3.0", + "@fontsource/inter": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "better-sqlite3": "^11.3.0" }, "devDependencies": { diff --git a/src/app.html b/src/app.html index 17653cb..4ed2044 100644 --- a/src/app.html +++ b/src/app.html @@ -2,7 +2,7 @@ - + Electric Cherry diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 752bddc..e3e63c7 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -6,6 +6,28 @@ import schemaSql from './schema.sql?raw'; let _db: Database.Database | null = null; +// Columns that were added to `servers` after v0.1 shipped. Applied on every +// boot; better-sqlite3 has no `ADD COLUMN IF NOT EXISTS`, so we run each and +// swallow the "duplicate column" error. +const SERVERS_MIGRATIONS: Array<{ column: string; ddl: string }> = [ + { column: 'last_map', ddl: 'ALTER TABLE servers ADD COLUMN last_map TEXT' }, + { column: 'last_gametype', ddl: 'ALTER TABLE servers ADD COLUMN last_gametype TEXT' }, + { column: 'last_roster', ddl: 'ALTER TABLE servers ADD COLUMN last_roster TEXT' }, + { column: 'last_seen_at', ddl: 'ALTER TABLE servers ADD COLUMN last_seen_at TEXT' }, + { column: 'last_error', ddl: 'ALTER TABLE servers ADD COLUMN last_error TEXT' } +]; + +function migrateServers(handle: Database.Database): void { + for (const m of SERVERS_MIGRATIONS) { + try { + handle.exec(m.ddl); + } catch (err) { + const msg = (err as Error).message ?? ''; + if (!/duplicate column name/i.test(msg)) throw err; + } + } +} + export function db(): Database.Database { if (_db) return _db; @@ -14,6 +36,7 @@ export function db(): Database.Database { _db.pragma('journal_mode = WAL'); _db.pragma('foreign_keys = ON'); _db.exec(schemaSql); + migrateServers(_db); return _db; } diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 422ae9e..1d42b72 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -13,6 +13,11 @@ CREATE TABLE IF NOT EXISTS servers ( rcon_host TEXT, -- optional per-server override of RCON_HOST last_players INTEGER, last_checked_at TEXT, + last_map TEXT, -- from RCON getstatus mapname + last_gametype TEXT, -- from RCON getstatus g_gametype + last_roster TEXT, -- JSON array of RconPlayer + last_seen_at TEXT, -- ISO ts of last successful RCON reply + last_error TEXT, -- last RCON failure reason (cleared on success) created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); diff --git a/src/lib/server/fleet/health.ts b/src/lib/server/fleet/health.ts new file mode 100644 index 0000000..0e53cef --- /dev/null +++ b/src/lib/server/fleet/health.ts @@ -0,0 +1,22 @@ +export type Health = 'online' | 'stale' | 'offline'; + +const ONLINE_MAX_MS = 30_000; +const STALE_MAX_MS = 120_000; + +/** + * Classify server health from its last successful RCON timestamp. + * `offline` if we've never seen it, or if RCON just failed, or if the last + * successful reply is >120s old. `stale` if the last reply is 30-120s old + * (RCON glitchy). `online` if fresh. + */ +export function classifyHealth(lastSeenAt: string | null, lastError: string | null): Health { + if (!lastSeenAt) return 'offline'; + const age = Date.now() - new Date(lastSeenAt).getTime(); + if (!Number.isFinite(age) || age < 0) return 'offline'; + if (age <= ONLINE_MAX_MS) { + // Even with a fresh last_seen, a very recent error demotes to stale + return lastError ? 'stale' : 'online'; + } + if (age <= STALE_MAX_MS) return 'stale'; + return 'offline'; +} diff --git a/src/lib/server/fleet/servers.ts b/src/lib/server/fleet/servers.ts index 51a1449..b63874c 100644 --- a/src/lib/server/fleet/servers.ts +++ b/src/lib/server/fleet/servers.ts @@ -71,6 +71,11 @@ export interface ServerRow { rcon_host: string | null; last_players: number | null; last_checked_at: string | null; + last_map: string | null; + last_gametype: string | null; + last_roster: string | null; // JSON array of RconPlayer, or null + last_seen_at: string | null; // last successful RCON reply + last_error: string | null; // cleared on success created_at: string; } @@ -116,7 +121,12 @@ export function setServerField( 'game_port', 'rcon_host', 'last_players', - 'last_checked_at' + 'last_checked_at', + 'last_map', + 'last_gametype', + 'last_roster', + 'last_seen_at', + 'last_error' ]; if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`); db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name); diff --git a/src/lib/server/integrations/rcon.ts b/src/lib/server/integrations/rcon.ts index 4822ee8..fe444a7 100644 --- a/src/lib/server/integrations/rcon.ts +++ b/src/lib/server/integrations/rcon.ts @@ -131,6 +131,61 @@ export function parseInfoLine(text: string): Record { return out; } +export interface RconPlayer { + num: number | null; // client slot — print dialect only + score: number; + ping: number; + guid: string | null; // 32-hex — print dialect only + name: string; +} + +/** + * Extract every player row from a `status` (print) or `getstatus` + * (statusResponse) response. Returns [] when the payload has none. + * Two dialects, same output shape: + * + * print: num score ping <32-hex-guid> + * statusResponse: score ping "" + */ +export function parsePlayerRoster(text: string): RconPlayer[] { + const lines = text.split(/\r?\n/); + const sep = lines.findIndex((l) => /^-+\s+-+/.test(l.trim())); + if (sep !== -1) { + const out: RconPlayer[] = []; + // Match: num score ping guid(32-hex) name-rest + const re = /^\s*(\d+)\s+(-?\d+)\s+(\d+)\s+([0-9a-fA-F]{16,})\s+(.+?)\s*$/; + for (let i = sep + 1; i < lines.length; i++) { + const m = re.exec(lines[i]); + if (!m) continue; + out.push({ + num: Number(m[1]), + score: Number(m[2]), + ping: Number(m[3]), + guid: m[4], + name: m[5] + }); + } + return out; + } + // statusResponse dialect: rows after the info line, format: score ping "name" + const info = lines.findIndex((l) => l.startsWith('\\')); + if (info === -1) return []; + const out: RconPlayer[] = []; + const re = /^\s*(-?\d+)\s+(\d+)\s+"(.*)"\s*$/; + for (let i = info + 1; i < lines.length; i++) { + const m = re.exec(lines[i]); + if (!m) continue; + out.push({ + num: null, + score: Number(m[1]), + ping: Number(m[2]), + guid: null, + name: m[3] + }); + } + return out; +} + export async function rconGetPlayerCount(server: string): Promise { const ep = await endpointFor(server); if (!ep) return null; @@ -145,6 +200,7 @@ export interface RconStatus { players: number; map: string | null; gametype: string | null; + roster: RconPlayer[]; } export async function rconGetStatus(server: string): Promise { @@ -162,6 +218,7 @@ export async function rconGetStatus(server: string): Promise return { players, map: info.mapname ?? info.sv_mapname ?? null, - gametype: info.g_gametype ?? null + gametype: info.g_gametype ?? null, + roster: parsePlayerRoster(text) }; } diff --git a/src/lib/server/pipeline/bus.ts b/src/lib/server/pipeline/bus.ts index 4429cbf..f1ed8ef 100644 --- a/src/lib/server/pipeline/bus.ts +++ b/src/lib/server/pipeline/bus.ts @@ -1,9 +1,22 @@ import { EventEmitter } from 'node:events'; +import type { RconPlayer } from '../integrations/rcon.js'; +import type { Health } from '../fleet/health.js'; // One in-process event bus, multiplexed by the SSE endpoints. Kept separate // from any specific subscriber so push/beta/sweep can emit without pulling // the whole worker module in. +export interface ServerStatusEntry { + server: string; + players: number | null; + map: string | null; + gametype: string | null; + roster: RconPlayer[] | null; + health: Health; + lastSeenAt: string | null; + lastError: string | null; +} + export type PipelineEvent = | { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] } | { type: 'server-changed'; deploymentId: string; server: string; files: number } @@ -16,7 +29,7 @@ export type PipelineEvent = error: string | null; } | { type: 'sweep-tick'; frozen: boolean; reason?: string } - | { type: 'server-status'; entries: Array<{ server: string; players: number | null; name?: string }> }; + | { type: 'server-status'; entries: ServerStatusEntry[] }; class Bus extends EventEmitter { constructor() { diff --git a/src/lib/server/pipeline/worker.ts b/src/lib/server/pipeline/worker.ts index 60bb2cf..2a0a948 100644 --- a/src/lib/server/pipeline/worker.ts +++ b/src/lib/server/pipeline/worker.ts @@ -1,7 +1,9 @@ import { sweep } from './sweep.js'; import { emit } from './bus.js'; +import type { ServerStatusEntry } from './bus.js'; import { rconGetStatus } from '../integrations/rcon.js'; -import { listServers, seedServerRegistry } from '../fleet/servers.js'; +import { listServers, seedServerRegistry, setServerField } from '../fleet/servers.js'; +import { classifyHealth } from '../fleet/health.js'; const TICK_MS = 15_000; const STATUS_REFRESH_MS = 30_000; @@ -25,7 +27,7 @@ async function fanoutStatus(): Promise { const servers = listServers(); if (servers.length === 0) return; - const entries: Array<{ server: string; players: number | null; name?: string }> = new Array(servers.length); + const entries: ServerStatusEntry[] = new Array(servers.length); let cursor = 0; async function pump(): Promise { @@ -34,11 +36,44 @@ async function fanoutStatus(): Promise { if (i >= servers.length) return; const srv = servers[i]; const status = await rconGetStatus(srv.name).catch(() => null); - entries[i] = { - server: srv.name, - players: status?.players ?? null, - name: status?.map ?? undefined - }; + const now = new Date().toISOString(); + + if (status) { + // Persist last-observed state. last_seen_at is the health anchor. + setServerField(srv.name, 'last_players', status.players); + setServerField(srv.name, 'last_map', status.map); + setServerField(srv.name, 'last_gametype', status.gametype); + setServerField(srv.name, 'last_roster', JSON.stringify(status.roster)); + setServerField(srv.name, 'last_seen_at', now); + setServerField(srv.name, 'last_checked_at', now); + setServerField(srv.name, 'last_error', null); + entries[i] = { + server: srv.name, + players: status.players, + map: status.map, + gametype: status.gametype, + roster: status.roster, + health: classifyHealth(now, null), + lastSeenAt: now, + lastError: null + }; + } else { + // RCON unreachable — leave last_seen_at alone (health decays + // to offline naturally), record the failure reason. + const error = 'rcon unreachable'; + setServerField(srv.name, 'last_checked_at', now); + setServerField(srv.name, 'last_error', error); + entries[i] = { + server: srv.name, + players: srv.last_players, + map: srv.last_map, + gametype: srv.last_gametype, + roster: srv.last_roster ? safeJson(srv.last_roster) : null, + health: classifyHealth(srv.last_seen_at, error), + lastSeenAt: srv.last_seen_at, + lastError: error + }; + } } } await Promise.all(Array.from({ length: STATUS_CONCURRENCY }, pump)); @@ -46,6 +81,14 @@ async function fanoutStatus(): Promise { emit({ type: 'server-status', entries }); } +function safeJson(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + export function startWorker(): void { if (started) return; started = true; diff --git a/src/lib/ui/components/BetaPanel.svelte b/src/lib/ui/components/BetaPanel.svelte index 3f2a7f8..8cc62ec 100644 --- a/src/lib/ui/components/BetaPanel.svelte +++ b/src/lib/ui/components/BetaPanel.svelte @@ -111,7 +111,7 @@
servers {b.servers.join(', ')}
- +
@@ -125,77 +125,147 @@ diff --git a/src/lib/ui/components/BrandMark.svelte b/src/lib/ui/components/BrandMark.svelte new file mode 100644 index 0000000..7cc80e6 --- /dev/null +++ b/src/lib/ui/components/BrandMark.svelte @@ -0,0 +1,37 @@ + + + + + ELECTRIC CHERRY + + + diff --git a/src/lib/ui/components/DiffViewer.svelte b/src/lib/ui/components/DiffViewer.svelte index c0888b2..8a67502 100644 --- a/src/lib/ui/components/DiffViewer.svelte +++ b/src/lib/ui/components/DiffViewer.svelte @@ -7,9 +7,6 @@ } let { before, after, verb, relPath }: Props = $props(); - // A tiny line-by-line diff. Not LCS — just presence-based. For GSC scripts - // this reads fine; if we want proper Myers diff later, swap in `diff` npm - // package and render the same rows. interface Row { kind: 'ctx' | 'add' | 'del'; text: string; @@ -24,7 +21,6 @@ const bsetBase: Row[] = blines.map((t) => ({ kind: 'add' as const, text: t })); if (a === null) return bsetBase; if (b === null) return asetBase; - // interleave shared-prefix / shared-suffix trimming for readability let i = 0; while (i < alines.length && i < blines.length && alines[i] === blines[i]) i++; let j = 0; @@ -55,41 +51,62 @@ diff --git a/src/lib/ui/components/PipelineTimeline.svelte b/src/lib/ui/components/PipelineTimeline.svelte index 36d8ca5..523d1e2 100644 --- a/src/lib/ui/components/PipelineTimeline.svelte +++ b/src/lib/ui/components/PipelineTimeline.svelte @@ -8,24 +8,8 @@ .slice(0, 8) ); - function stateColor(state: string): string { - switch (state) { - case 'done': return '#2d4a2d'; - case 'failed': return '#4a2d2d'; - case 'pending': return '#3a2f00'; - case 'manual': return '#333'; - default: return '#222'; - } - } - - function stateFg(state: string): string { - switch (state) { - case 'done': return '#a8e6a8'; - case 'failed': return '#ff8080'; - case 'pending': return '#ffcc55'; - case 'manual': return '#aaa'; - default: return '#888'; - } + function stateClass(state: string): string { + return `chip chip-${state}`; } @@ -46,8 +30,7 @@
{#each [...dep.servers.entries()] as [srv, meta] (srv)} {srv}{meta.players !== null && meta.players > 0 ? ` ${meta.players}p` : ''} @@ -62,43 +45,84 @@ diff --git a/src/lib/ui/components/PlayerRoster.svelte b/src/lib/ui/components/PlayerRoster.svelte new file mode 100644 index 0000000..0944d69 --- /dev/null +++ b/src/lib/ui/components/PlayerRoster.svelte @@ -0,0 +1,111 @@ + + +{#if health === 'offline'} +
server unreachable
+{:else if !roster || roster.length === 0} +
lobby is empty
+{:else} + + + + {#if !compact}{/if} + + + + {#if !compact}{/if} + + + + {#each sorted as p (p.guid ?? p.name + '-' + p.num)} + + {#if !compact}{/if} + + + + {#if !compact}{/if} + + {/each} + +
#playerscorepingguid
{p.num ?? ''}{p.name || '—'}{p.score}{p.ping}{p.guid ? p.guid.slice(0, 8) + '…' : '—'}
+{/if} + + diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index 535899b..1215111 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -1,4 +1,8 @@ - +
- {name} - 0}> - {players ?? '—'} - +
+ + {name} +
+ {#if health !== 'offline'} + 0}> + {players ?? '—'} + + {:else} + offline + {/if}
+
- restart: {ago(lastRestartedAt)} + {mapLabel} + {#if gtLabel} + · + {gtLabel} + {/if}
+ + {#if showRoster} + {#if topRoster.length > 0} +
    + {#each topRoster as p (p.guid ?? p.name)} +
  • + {p.name || '—'} + {p.score} + {p.ping}ms +
  • + {/each} +
+ {:else if health !== 'offline'} +
lobby is empty
+ {/if} + {/if} + {#if held.length > 0}
{#each held as h (h.target)} @@ -39,6 +100,19 @@ {/each}
{/if} + +
+ {#if health === 'offline' && lastError} + {lastError} + {:else if lastSeenAt} + last seen {ago(lastSeenAt)} + {:else if lastRestartedAt} + restart {ago(lastRestartedAt)} + {:else} + no data + {/if} +
+ {#if restarting}
{/if} @@ -46,50 +120,165 @@ diff --git a/src/lib/ui/components/StatusDot.svelte b/src/lib/ui/components/StatusDot.svelte new file mode 100644 index 0000000..c6a3054 --- /dev/null +++ b/src/lib/ui/components/StatusDot.svelte @@ -0,0 +1,37 @@ + + + + + diff --git a/src/lib/ui/gametype.ts b/src/lib/ui/gametype.ts new file mode 100644 index 0000000..94aa63f --- /dev/null +++ b/src/lib/ui/gametype.ts @@ -0,0 +1,21 @@ +// Friendly names for CoD gametype codes reported over RCON. +const NAMES: Record = { + zclassic: 'Zombies Classic', + zstandard: 'Zombies Standard', + zgrief: 'Grief', + zcleansed: 'Cleansed', + ztag: 'Tag der Toten', + dm: 'Deathmatch', + tdm: 'Team Deathmatch', + sd: 'Search & Destroy', + dom: 'Domination', + war: 'War', + hq: 'Headquarters', + ctf: 'Capture the Flag' +}; + +export function gametypeLabel(code: string | null | undefined): string { + if (!code) return ''; + const key = code.toLowerCase(); + return NAMES[key] ?? code; +} diff --git a/src/lib/ui/stores/servers.ts b/src/lib/ui/stores/servers.ts index fd88c19..0b9332f 100644 --- a/src/lib/ui/stores/servers.ts +++ b/src/lib/ui/stores/servers.ts @@ -1,10 +1,25 @@ import { writable } from 'svelte/store'; import { browser } from '$app/environment'; +export interface RosterPlayer { + num: number | null; + score: number; + ping: number; + guid: string | null; + name: string; +} + +export type Health = 'online' | 'stale' | 'offline'; + export interface ServerStatus { server: string; players: number | null; - name?: string; + map: string | null; + gametype: string | null; + roster: RosterPlayer[] | null; + health: Health; + lastSeenAt: string | null; + lastError: string | null; } export const statusByServer = writable>(new Map()); diff --git a/src/lib/ui/styles/tokens.css b/src/lib/ui/styles/tokens.css new file mode 100644 index 0000000..fa06def --- /dev/null +++ b/src/lib/ui/styles/tokens.css @@ -0,0 +1,78 @@ +/* Electric Cherry — design tokens. + Named after the perk-a-cola from Mob of the Dead (CoD Zombies): + deep cherry-red art-deco label + electric cyan discharge. */ + +@import '@fontsource/alfa-slab-one/400.css'; +@import '@fontsource/inter/400.css'; +@import '@fontsource/inter/500.css'; +@import '@fontsource/inter/600.css'; +@import '@fontsource/inter/700.css'; +@import '@fontsource/jetbrains-mono/400.css'; +@import '@fontsource/jetbrains-mono/500.css'; + +:root { + /* Cherry — surfaces + primary */ + --cherry-975: #0b0407; + --cherry-925: #170a0f; + --cherry-875: #22101a; + --cherry-825: #2c1420; + --cherry-700: #4a0f1e; + --cherry-600: #7a0f22; + --cherry-500: #c8102e; + --cherry-400: #e93656; + --cherry-300: #ff7a92; + + /* Spark — electric cyan */ + --spark-600: #14b8d0; + --spark-500: #22e0ff; + --spark-400: #7ef1ff; + --spark-glow: 0 0 8px rgba(34, 224, 255, 0.55); + --spark-glow-lg: 0 0 14px rgba(34, 224, 255, 0.45); + + /* Neutrals + label */ + --cream: #f2e6c8; + --cream-dim: #c9b98f; + --ink-100: #f5eaec; + --ink-300: #c8a4ac; + --ink-500: #7a5560; + --ink-700: #4a2f36; + + /* State colors */ + --amber-500: #ffb020; + --amber-glow: 0 0 8px rgba(255, 176, 32, 0.4); + --danger-500: #ff3050; + --success-500: var(--spark-500); + + /* Typography */ + --font-display: 'Alfa Slab One', 'Georgia', serif; + --font-body: 'Inter', system-ui, -apple-system, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; + + /* Radii */ + --r-sm: 3px; + --r-md: 6px; + --r-lg: 10px; + + /* Shadows */ + --shadow-card: 0 2px 12px rgba(0, 0, 0, 0.5); + --shadow-panel: 0 4px 24px rgba(0, 0, 0, 0.4); +} + +@keyframes electric-pulse { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(34, 224, 255, 0.55), var(--spark-glow); + } + 50% { + box-shadow: 0 0 0 6px rgba(34, 224, 255, 0), var(--spark-glow-lg); + } +} + +@keyframes cherry-fizz { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-1px); } +} + +@keyframes spark-slide { + from { background-position: -100% 0; } + to { background-position: 200% 0; } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index aa6c3fa..aee866f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import { startPipelineStream } from '$ui/stores/pipeline.js'; import { startServerStream } from '$ui/stores/servers.js'; import { page } from '$app/stores'; + import BrandMark from '$ui/components/BrandMark.svelte'; interface Props { children: import('svelte').Snippet } let { children }: Props = $props(); @@ -15,10 +16,15 @@
{@render children()} @@ -26,38 +32,78 @@
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f10398a..f6a7113 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3,6 +3,7 @@ import ServerCard from '$ui/components/ServerCard.svelte'; import PipelineTimeline from '$ui/components/PipelineTimeline.svelte'; import { statusByServer } from '$ui/stores/servers.js'; + import type { RosterPlayer, Health } from '$ui/stores/servers.js'; interface HeldRow { target: string; slug: string; } interface ServerRow { @@ -12,6 +13,12 @@ game_port: number | null; rcon_host: string | null; last_players: number | null; + last_map: string | null; + last_gametype: string | null; + last_seen_at: string | null; + last_error: string | null; + roster: RosterPlayer[] | null; + health: Health; held: HeldRow[]; } @@ -44,16 +51,39 @@ : `pushed to ${body.totalChangedServers ?? 0} servers · ${body.totalFiles ?? 0} files`; } - function playersFor(name: string): number | null { - const s = $statusByServer.get(name); - return s?.players ?? null; + // Live server view — merge SSE data over the initial GET + function liveFor(name: string, fallback: ServerRow) { + const live = $statusByServer.get(name); + if (!live) { + return { + players: fallback.last_players, + map: fallback.last_map, + gametype: fallback.last_gametype, + roster: fallback.roster, + health: fallback.health, + lastSeenAt: fallback.last_seen_at, + lastError: fallback.last_error + }; + } + return { + players: live.players, + map: live.map, + gametype: live.gametype, + roster: live.roster, + health: live.health, + lastSeenAt: live.lastSeenAt, + lastError: live.lastError + }; }
-

Fleet

+
+

Fleet

+ {servers.length} server{servers.length === 1 ? '' : 's'} +
@@ -65,9 +95,16 @@ {:else}
{#each servers as s (s.name)} + {@const live = liveFor(s.name, s)} {/each} @@ -84,7 +121,7 @@ .grid { display: grid; grid-template-columns: 1fr 380px; - gap: 20px; + gap: 24px; } @media (max-width: 900px) { .grid { grid-template-columns: 1fr; } @@ -92,32 +129,77 @@ .hdr { display: flex; justify-content: space-between; - align-items: baseline; - margin-bottom: 16px; + align-items: flex-end; + margin-bottom: 20px; + padding-bottom: 14px; + border-bottom: 1px solid var(--cherry-700); } + .title-block { display: flex; align-items: baseline; gap: 14px; } h1 { margin: 0; - font-size: 22px; - color: #ff6060; + font-family: var(--font-display); + font-size: 32px; + color: var(--cream); + letter-spacing: 2px; + line-height: 1; + } + .count { + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-500); letter-spacing: 0.5px; } .actions { display: flex; gap: 8px; } button { - background: #222; - color: #ddd; - border: 1px solid #444; - border-radius: 4px; - padding: 8px 14px; + background: var(--cherry-875); + color: var(--ink-100); + border: 1px solid var(--cherry-700); + border-radius: var(--r-sm); + padding: 8px 16px; cursor: pointer; + font-family: var(--font-body); font-size: 13px; + font-weight: 500; + letter-spacing: 0.3px; + transition: border-color 0.15s, box-shadow 0.15s; } - button.primary { background: #ff3030; color: #fff; border-color: #ff3030; font-weight: 600; } - button:disabled { opacity: 0.5; cursor: default; } + button:hover:not(:disabled) { + border-color: var(--spark-500); + box-shadow: var(--spark-glow); + } + button.primary { + background: linear-gradient(180deg, var(--cherry-500), var(--cherry-600)); + color: var(--cream); + border-color: var(--cherry-400); + font-weight: 600; + } + button.primary:hover:not(:disabled) { + border-color: var(--spark-500); + box-shadow: var(--spark-glow), 0 0 20px rgba(200, 16, 46, 0.4); + } + button:disabled { opacity: 0.4; cursor: default; } .cards { display: grid; - gap: 10px; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 14px; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } + .empty { + color: var(--ink-500); + font-family: var(--font-mono); + font-size: 13px; + padding: 40px; + text-align: center; + border: 1px dashed var(--cherry-700); + border-radius: var(--r-md); + } + .flash { + color: var(--spark-400); + margin: 0 0 16px; + font-family: var(--font-mono); + font-size: 12px; + padding: 10px 14px; + background: rgba(34, 224, 255, 0.06); + border: 1px solid rgba(34, 224, 255, 0.25); + border-radius: var(--r-sm); } - .empty { color: #666; } - .flash { color: #aaa; margin: 0 0 12px; font-family: ui-monospace, monospace; font-size: 12px; } diff --git a/src/routes/api/servers/+server.ts b/src/routes/api/servers/+server.ts index 7ff464c..2121a83 100644 --- a/src/routes/api/servers/+server.ts +++ b/src/routes/api/servers/+server.ts @@ -1,9 +1,22 @@ import { json } from '@sveltejs/kit'; import { listServers, heldByBeta } from '$lib/server/fleet/servers.js'; +import { classifyHealth } from '$lib/server/fleet/health.js'; +import type { RconPlayer } from '$lib/server/integrations/rcon.js'; + +function safeJson(raw: string | null): T | null { + if (!raw) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} export async function GET() { const servers = listServers().map((s) => ({ ...s, + roster: safeJson(s.last_roster), + health: classifyHealth(s.last_seen_at, s.last_error), held: [...heldByBeta(s.name).entries()].map(([target, slug]) => ({ target, slug })) })); return json({ servers }); diff --git a/src/routes/betas/+page.svelte b/src/routes/betas/+page.svelte index 64a3678..805b9ed 100644 --- a/src/routes/betas/+page.svelte +++ b/src/routes/betas/+page.svelte @@ -27,6 +27,16 @@ {/if} diff --git a/src/routes/deployments/+page.svelte b/src/routes/deployments/+page.svelte index f260397..3a4b2ea 100644 --- a/src/routes/deployments/+page.svelte +++ b/src/routes/deployments/+page.svelte @@ -31,14 +31,8 @@ return d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed'); } - function chipColor(state: string): { bg: string; fg: string } { - switch (state) { - case 'done': return { bg: '#2d4a2d', fg: '#a8e6a8' }; - case 'failed': return { bg: '#4a2d2d', fg: '#ff8080' }; - case 'pending': return { bg: '#3a2f00', fg: '#ffcc55' }; - case 'manual': return { bg: '#333', fg: '#aaa' }; - default: return { bg: '#222', fg: '#888' }; - } + function chipClass(state: string): string { + return `chip chip-${state}`; } function ago(iso: string): string { @@ -89,8 +83,7 @@
{#each d.servers as s (s.server)} - {@const c = chipColor(s.restart_state)} - + {s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''} {/each} @@ -100,35 +93,78 @@ {/snippet} diff --git a/src/routes/deployments/[id]/+page.svelte b/src/routes/deployments/[id]/+page.svelte index 7c9e8fe..881deda 100644 --- a/src/routes/deployments/[id]/+page.svelte +++ b/src/routes/deployments/[id]/+page.svelte @@ -116,88 +116,146 @@ {/if} diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index d509a72..41281e2 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -2,6 +2,10 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; import { statusByServer } from '$ui/stores/servers.js'; + import type { RosterPlayer, Health } from '$ui/stores/servers.js'; + import StatusDot from '$ui/components/StatusDot.svelte'; + import PlayerRoster from '$ui/components/PlayerRoster.svelte'; + import { gametypeLabel } from '$ui/gametype.js'; interface Server { name: string; @@ -12,6 +16,12 @@ rcon_host: string | null; last_players: number | null; last_checked_at: string | null; + last_map: string | null; + last_gametype: string | null; + last_seen_at: string | null; + last_error: string | null; + roster: RosterPlayer[] | null; + health: Health; held: Array<{ target: string; slug: string }>; } interface DeploymentRow { @@ -34,96 +44,243 @@ history = depBody.deployments.filter((d) => d.servers.some((s) => s.server === name)); }); - let live = $derived($statusByServer.get(name)?.players); + let live = $derived($statusByServer.get(name)); + let effectivePlayers = $derived(live?.players ?? server?.last_players ?? null); + let effectiveMap = $derived(live?.map ?? server?.last_map ?? null); + let effectiveGametype = $derived(live?.gametype ?? server?.last_gametype ?? null); + let effectiveRoster = $derived(live?.roster ?? server?.roster ?? null); + let effectiveHealth = $derived(live?.health ?? server?.health ?? 'offline'); + let effectiveSeen = $derived(live?.lastSeenAt ?? server?.last_seen_at ?? null); + let effectiveErr = $derived(live?.lastError ?? server?.last_error ?? null); + + function ago(iso: string | null): string { + if (!iso) return 'never'; + const d = Date.now() - new Date(iso).getTime(); + if (d < 60_000) return `${Math.max(1, Math.floor(d / 1000))}s ago`; + if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`; + if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`; + return `${Math.floor(d / 86_400_000)}d ago`; + } {#if server} -

{server.name}

-
-
-
players
-
{live ?? server.last_players ?? '—'}
+
+
+
+ +

{server.name}

+
+
+
0}> + {effectivePlayers ?? '—'} +
+
players
+
-
-
komodo
-
{server.komodo_type} · {server.komodo_name}
+
+ map {effectiveMap ?? '—'} + mode {gametypeLabel(effectiveGametype) || '—'} + status {effectiveHealth} + last seen {ago(effectiveSeen)}
-
-
rcon
-
{server.rcon_host ?? 'default'} · port {server.game_port ?? '—'}
-
-
-
last check
-
{server.last_checked_at ?? '—'}
-
-
-
held by beta
-
{server.held.length === 0 ? '—' : server.held.map((h) => h.target).join(', ')}
-
-
+ {#if effectiveErr && effectiveHealth !== 'online'} +
RCON: {effectiveErr}
+ {/if} +
-

Deployment history

- {#if history.length === 0} -

no deployments recorded on this server

- {:else} - {#each history as d (d.id)} - {@const mine = d.servers.find((s) => s.server === name)!} - - {d.action} - {mine.restart_state}{mine.players !== null && mine.players > 0 ? ` (${mine.players}p)` : ''} - {mine.files_changed} file{mine.files_changed === 1 ? '' : 's'} - {d.started_at} - {#if mine.error}{mine.error}{/if} - - {/each} - {/if} +
+

Roster

+ +
+ +
+

Config

+
+
+
komodo
+
{server.komodo_type} · {server.komodo_name}
+
+
+
rcon
+
{server.rcon_host ?? 'default'} · port {server.game_port ?? '—'}
+
+
+
held by beta
+
{server.held.length === 0 ? '—' : server.held.map((h) => h.target).join(', ')}
+
+
+
+ +
+

Deployment history

+ {#if history.length === 0} +

no deployments recorded on this server

+ {:else} + {#each history as d (d.id)} + {@const mine = d.servers.find((s) => s.server === name)!} + + {d.action} + {mine.restart_state}{mine.players !== null && mine.players > 0 ? ` (${mine.players}p)` : ''} + {mine.files_changed} file{mine.files_changed === 1 ? '' : 's'} + {ago(d.started_at)} + {#if mine.error}{mine.error}{/if} + + {/each} + {/if} +
{:else}

loading…

{/if} diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..46e98cf --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/tests/unit/rcon.test.ts b/tests/unit/rcon.test.ts index ebe07ea..e5caac8 100644 --- a/tests/unit/rcon.test.ts +++ b/tests/unit/rcon.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseInfoLine, parsePlayerCount } from '../../src/lib/server/integrations/rcon.js'; +import { parseInfoLine, parsePlayerCount, parsePlayerRoster } from '../../src/lib/server/integrations/rcon.js'; describe('parsePlayerCount', () => { it('counts rows after --- --- separator (status dialect)', () => { @@ -57,3 +57,59 @@ describe('parseInfoLine', () => { expect(parseInfoLine('nothing here')).toEqual({}); }); }); + +describe('parsePlayerRoster', () => { + it('extracts num/score/ping/guid/name from print dialect', () => { + const body = [ + 'print', + 'map: mp_kino', + 'num score ping guid name', + '--- ----- ---- -------- ----', + ' 0 12 45 aaaaaaaabbbbbbbbccccccccdddddddd joe', + ' 1 0 72 11112222333344445555666677778888 jill h.', + ' 2 999 128 abcdef0123456789abcdef0123456789 Toaster' + ].join('\n'); + const roster = parsePlayerRoster(body); + expect(roster).toHaveLength(3); + expect(roster[0]).toEqual({ + num: 0, + score: 12, + ping: 45, + guid: 'aaaaaaaabbbbbbbbccccccccdddddddd', + name: 'joe' + }); + // Names with spaces stay intact + expect(roster[1].name).toBe('jill h.'); + expect(roster[2].ping).toBe(128); + }); + + it('extracts score/ping/quoted-name from statusResponse dialect', () => { + const body = [ + 'statusResponse', + '\\mapname\\mp_kino\\g_gametype\\zclassic', + '10 45 "joe"', + '0 72 "jill h."' + ].join('\n'); + const roster = parsePlayerRoster(body); + expect(roster).toHaveLength(2); + expect(roster[0]).toEqual({ + num: null, + score: 10, + ping: 45, + guid: null, + name: 'joe' + }); + expect(roster[1].name).toBe('jill h.'); + }); + + it('returns empty array when no rows are present', () => { + const empty = [ + 'print', + 'map: mp_ascension', + 'num score ping guid name', + '--- ----- ---- -------- ----' + ].join('\n'); + expect(parsePlayerRoster(empty)).toEqual([]); + expect(parsePlayerRoster('nonsense')).toEqual([]); + }); +});