From b8cccc5a98c534a95197e7a35b1709286100e504 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Tue, 6 Oct 2026 17:42:05 -0400 Subject: [PATCH] Simplify instance details and clarify diagnostics --- src/lib/fleet-overview.ts | 2 + src/lib/server/fleet/overview.ts | 1 + src/lib/ui/components/ChatPanel.svelte | 15 +- src/lib/ui/components/ConfigInspector.svelte | 12 +- src/lib/ui/components/LogPanel.svelte | 9 +- src/lib/ui/components/MatchHistory.svelte | 15 +- src/lib/ui/components/PlayerScoreboard.svelte | 33 +- .../ui/components/TelemetryDiagnostics.svelte | 28 +- src/lib/ui/log-refresh.ts | 5 +- src/lib/ui/server-activity.ts | 30 ++ .../api/servers/[name]/scripts/+server.ts | 11 +- src/routes/servers/[name]/+page.svelte | 341 ++++-------------- tests/unit/instance-inventory.test.ts | 27 ++ tests/unit/log-refresh.test.ts | 25 ++ tests/unit/ui-server-detail.test.ts | 98 +++++ 15 files changed, 329 insertions(+), 323 deletions(-) create mode 100644 src/lib/ui/server-activity.ts create mode 100644 tests/unit/instance-inventory.test.ts create mode 100644 tests/unit/ui-server-detail.test.ts diff --git a/src/lib/fleet-overview.ts b/src/lib/fleet-overview.ts index b0e276c..ff5a329 100644 --- a/src/lib/fleet-overview.ts +++ b/src/lib/fleet-overview.ts @@ -44,6 +44,8 @@ export interface FleetOperation { href: string; state: 'active' | 'attention' | 'done'; startedAt: string; + activityLabel?: string; + resultState?: string; } export type FleetFilter = 'all' | 'occupied' | 'empty' | 'attention' | 'testing' | 'pinned'; diff --git a/src/lib/server/fleet/overview.ts b/src/lib/server/fleet/overview.ts index dab98aa..014e559 100644 --- a/src/lib/server/fleet/overview.ts +++ b/src/lib/server/fleet/overview.ts @@ -66,6 +66,7 @@ function readOperations(now: number, server?: string): FleetOperation[] { : ['failed', 'interrupted'].includes(row.state) ? 'attention' : 'active'; operations.push({ key: `deployment:${row.id}`, group: `deployment:${row.id}`, kind: 'deployment', server: row.server, state, startedAt: row.started_at, + activityLabel: `${actionLabels[row.action as ProvisioningAction] ?? row.action} instance`, resultState: row.state, label: state === 'done' ? row.state === 'cancelled' ? 'Removal cancelled' : row.state === 'warning' ? 'Completed with warnings' : 'Complete' : state === 'attention' ? `${actionLabels[row.action as ProvisioningAction]} needs attention` : row.stage, detail: row.error ?? row.stage, href: `/provisioning/${encodeURIComponent(row.id)}` }); diff --git a/src/lib/ui/components/ChatPanel.svelte b/src/lib/ui/components/ChatPanel.svelte index 236c38a..360443b 100644 --- a/src/lib/ui/components/ChatPanel.svelte +++ b/src/lib/ui/components/ChatPanel.svelte @@ -18,6 +18,7 @@ let missing = $state(false); let body = $state(null); let follow = $state(true); + let paused = $state(false); let controller: AbortController | null = null; async function refresh(manual = false) { @@ -31,9 +32,10 @@ controller = request; try { const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, { signal: request.signal }); - const data = await response.json(); - if (!response.ok || !data.ok) throw new Error(data.reason ?? 'Could not refresh chat.'); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.ok) throw new Error('Could not refresh chat. Try again shortly.'); if (request.signal.aborted) return; + if (paused && !manual) return; entries = data.entries ?? []; missing = !!data.missing; loaded = true; @@ -54,7 +56,7 @@ if (body) body.scrollTop = body.scrollHeight; } onMount(() => { - const stop = pollVisible(() => refresh(), 5000); + const stop = pollVisible(() => paused ? Promise.resolve() : refresh(), 5000); return () => { stop(); controller?.abort(); }; }); @@ -87,8 +89,9 @@
-

Chat

Updates every 5s
+

Chat

{paused ? 'Paused' : 'Live · 5s'}
+ {#if hasMessages}{/if} {#if !follow}{/if}
@@ -143,8 +146,8 @@ .chat-title { gap: var(--space-2) var(--space-3); } h2 { margin: 0; font-size: var(--text-lg); } .update-cadence { color: var(--text-secondary); font-size: var(--text-xs); } - .empty { color: var(--text-secondary); font-size: var(--text-sm); padding: var(--space-6) 0; } - .messages { max-height: 60vh; overflow: auto; margin-top: var(--space-4); } + .empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 8px 0; margin-bottom: 0; } + .messages { max-height: 360px; overflow: auto; margin-top: var(--space-4); } .messages:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent-primary); border-radius: var(--radius-sm); } ol { list-style: none; padding: 0; margin: 0; } .message { display: grid; grid-template-columns: 6rem minmax(0, 1fr); align-items: baseline; column-gap: var(--space-4); padding: var(--space-3) 0; } diff --git a/src/lib/ui/components/ConfigInspector.svelte b/src/lib/ui/components/ConfigInspector.svelte index a7d3826..f7ee727 100644 --- a/src/lib/ui/components/ConfigInspector.svelte +++ b/src/lib/ui/components/ConfigInspector.svelte @@ -74,18 +74,18 @@
-
{artifactId ? 'Draft preview' : 'Follow every value to its source'}

{artifactId ? 'On this server' : 'Promoted configuration'}

+
{#if !fixedServer}{artifactId ? 'Draft preview' : 'Follow every value to its source'}{/if}

{artifactId ? 'On this server' : 'Promoted configuration'}

{artifactId ? 'Your draft composed with the other promoted configs. Saving creates an untested version.' : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}

- + {/if} {#if error}{/if} -

{loading ? inspection ? 'Updating preview… Showing the last completed result.' : 'Composing configuration…' : inspection ? 'Preview up to date.' : ''}

+ {#if !fixedServer || loading}

{loading ? inspection ? 'Updating preview… Showing the last completed result.' : 'Composing configuration…' : inspection ? 'Preview up to date.' : ''}

{/if} {#if !eligible.length && !loading}

{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}

- {:else if inspection?.reason}

{inspection.reason}

+ {:else if inspection?.reason}

{fixedServer && inspection.reason.startsWith('No promoted baseline') ? `No promoted baseline for ${servers.find(item => item.name === fixedServer)?.game === 't6' ? 'Black Ops II' : servers.find(item => item.name === fixedServer)?.game === 't5' ? 'Black Ops I' : 'this game'}.` : inspection.reason}

{#if fixedServer}Open config library →{/if} {:else if inspection}
    {#each inspection.sources as source, index} @@ -154,7 +154,7 @@ small { display: block; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 3px; } .composed { display: block; width: 100%; box-sizing: border-box; margin-top: 16px; padding: 20px; background: var(--surface-sunken); color: var(--text-primary); border: 0; border-radius: var(--radius-sm); min-height: 260px; max-height: 600px; resize: vertical; overflow: auto; font: var(--text-xs)/1.8 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; } .notes { margin-top: 16px; color: var(--state-warning); font-size: var(--text-xs); } .notes summary { cursor: pointer; } .notes li { margin: 8px 0; } - .empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 24px 0; } + .empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 8px 0; } .error { color: var(--state-danger); margin-top: 16px; font-size: var(--text-sm); } @media (max-width: 600px) { .inspector { padding: 16px; } } input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); } diff --git a/src/lib/ui/components/LogPanel.svelte b/src/lib/ui/components/LogPanel.svelte index 7bf5dcf..1d50fa3 100644 --- a/src/lib/ui/components/LogPanel.svelte +++ b/src/lib/ui/components/LogPanel.svelte @@ -134,12 +134,12 @@
-
+
More
{#if copyStatus}

{copyStatus}

{/if}
- {#if refreshError}{/if} + {#if refreshError}{/if} {#if !current && !refreshError}
{loading ? 'loading…' : 'No log loaded. Select Refresh to load it.'}
{:else if current && !current.ok} @@ -165,7 +165,10 @@
diff --git a/src/lib/ui/components/PlayerScoreboard.svelte b/src/lib/ui/components/PlayerScoreboard.svelte index 47c4733..ed8c1e5 100644 --- a/src/lib/ui/components/PlayerScoreboard.svelte +++ b/src/lib/ui/components/PlayerScoreboard.svelte @@ -10,10 +10,12 @@ import Badge from './primitives/Badge.svelte'; import EmptyState from './primitives/EmptyState.svelte'; - let { report, mapLabel, current = true, after = null }: { report: GameTelemetry | undefined; mapLabel?: string; current?: boolean; after?: string | null } = $props(); + let { report, mapLabel, current = true, after = null, matchElapsed = null, activity }: { report: GameTelemetry | undefined; mapLabel?: string; current?: boolean; after?: string | null; matchElapsed?: string | null; activity?: string } = $props(); const fresh = $derived(current && freshTelemetry(report, $uiNow, after)); const timingNow = $derived(report && (report.event === 'game_end' || !fresh) ? Date.parse(report.receivedAt) : $uiNow); + const displayElapsed = $derived(fresh ? matchElapsed : report?.timing?.server?.gameStartedAt && !report.timing.server.gameEndedAt + ? elapsedSince(report.timing.server.gameStartedAt, timingNow) : null); const columns = [ { key: 'score', label: 'Score' }, { key: 'kills', label: 'Kills' }, @@ -24,6 +26,8 @@ type StatKey = typeof columns[number]['key']; let sortBy = $state('score'); let descending = $state(true); + let expanded = $state([]); + const uid = $props.id(); const number = new Intl.NumberFormat('en-US'); const players = $derived(report?.event === 'game_start' ? report.players.map(player => ({ ...player, score: null, kills: null, headshots: null, downs: null, revives: null })) @@ -48,12 +52,13 @@

{mapLabel ?? mapTitle(report.map)}

{#if report.event !== 'game_start'}Round {report.round}{/if} + {#if displayElapsed}Match {displayElapsed}{fresh ? '' : ' at last report'}{/if} {#if report.event === 'game_end'}Game ended {:else if report.event === 'game_start'}Game started{/if}
- {players.length} {players.length === 1 ? 'player' : 'players'} in this report + {players.length} {players.length === 1 ? 'player' : 'players'}{fresh ? '' : ' in last report'} {#if !fresh}Historical report{/if}
{/if} -

Times and joining rounds are estimated from player reports. Server time carries across consecutive matches; ended or stale reports show time at the last update.

{/if} diff --git a/src/lib/ui/components/TelemetryDiagnostics.svelte b/src/lib/ui/components/TelemetryDiagnostics.svelte index dfee4d6..be74eff 100644 --- a/src/lib/ui/components/TelemetryDiagnostics.svelte +++ b/src/lib/ui/components/TelemetryDiagnostics.svelte @@ -79,20 +79,10 @@ -

- Incoming POST /api/telemetry requests with this exact server name. - The scoreboard uses telemetry; the fleet player count comes from RCON separately. -

-

- Newest first · {retention.maxPerServer ? `up to ${retention.maxPerServer} requests per server` : 'no per-server retention cap'} · {retention.maxAgeDays ? `last ${retention.maxAgeDays} days` : 'no age expiry'}. Fleet-wide limits also apply. - Refreshes every 5 seconds while visible. - {#if refreshedAt}Last refreshed .{/if} -

- {#if error} + Couldn’t load telemetry requests. {#if loaded} Previously loaded requests are shown below.{/if} +

Technical details{error}
{/if} {#if !loaded && !error} @@ -133,14 +123,24 @@ {/if} +
About request logging

+ Incoming POST /api/telemetry requests with this exact server name. + Fresh game telemetry supplies player counts when available; RCON provides a fallback. The scoreboard uses telemetry. +

+

+ Newest first · {retention.maxPerServer ? `up to ${retention.maxPerServer} requests per server` : 'no per-server retention cap'} · {retention.maxAgeDays ? `last ${retention.maxAgeDays} days` : 'no age expiry'}. Fleet-wide limits also apply. + Refreshes every 5 seconds while visible. + {#if refreshedAt}Last refreshed .{/if} +

+

- Logging starts after this update is installed. Requests can be linked here only when readable JSON contains a matching top-level server field. Calls that never reach Electric Cherry, malformed JSON, and different server names will not appear here. -

+

diff --git a/tests/unit/instance-inventory.test.ts b/tests/unit/instance-inventory.test.ts new file mode 100644 index 0000000..e0cd8f8 --- /dev/null +++ b/tests/unit/instance-inventory.test.ts @@ -0,0 +1,27 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; +let fixture: Fixture; +const name = 't5_zombie_theater_1'; +beforeEach(async () => { + vi.resetModules(); fixture = await newFixture([name]); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); +}); +afterEach(async () => { await fixture.cleanup(); }); + +it('links only inventory whose exact content matches its library version', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + const artifactId = Number(db().prepare("INSERT INTO scripts(name, target_rel_path, kind) VALUES ('utils', 'utils.dll', 'plugin')").run().lastInsertRowid); + const versionId = Number(db().prepare("INSERT INTO script_versions(script_id, version, release_version, sha256, size) VALUES (?, 2, 'v1.1.0', 'known', 10)").run(artifactId).lastInsertRowid); + const { recordScript } = await import('../../src/lib/server/fleet/inventory.js'); + recordScript(name, 'utils.dll', versionId, 'known', 10, 'native-create'); + recordScript(name, 'changed.dll', versionId, 'different', 12, 'native-create'); + recordScript(name, 'unknown.gsc', null, 'unknown', 2, 'native-create'); + const { GET } = await import('../../src/routes/api/servers/[name]/scripts/+server.js'); + const response = await GET({ params: { name } }); + expect(response.status).toBe(200); + const { scripts } = await response.json(); + expect(scripts.find((item: { relPath: string }) => item.relPath === 'utils.dll')).toMatchObject({ artifactId, version: 2, releaseVersion: 'v1.1.0', kind: 'plugin' }); + expect(scripts.find((item: { relPath: string }) => item.relPath === 'changed.dll')).toMatchObject({ artifactId: null, version: null, releaseVersion: null }); + expect(scripts.find((item: { relPath: string }) => item.relPath === 'unknown.gsc')).toMatchObject({ artifactId: null, version: null, kind: 'script' }); +}); diff --git a/tests/unit/log-refresh.test.ts b/tests/unit/log-refresh.test.ts index 0275ef7..19afe09 100644 --- a/tests/unit/log-refresh.test.ts +++ b/tests/unit/log-refresh.test.ts @@ -184,3 +184,28 @@ describe('log refresh controls', () => { expect(onLoading).not.toHaveBeenCalled(); }); }); + +it('keeps the last tail on a non-JSON gateway failure and can recover with a retry', async () => { + const { fetcher, onData, onError } = setup(); + fetcher.mockResolvedValueOnce(response('saved')) + .mockResolvedValueOnce(new Response('Bad Gateway', { status: 502 })) + .mockResolvedValueOnce(response('recovered')); + controller.configure(target, false, 5000); + await controller.refresh(); + await controller.refresh(); + expect(onError).toHaveBeenLastCalledWith('HTTP 502: Expected a JSON log response.'); + expect(onData).toHaveBeenCalledTimes(1); + expect(onData).toHaveBeenLastCalledWith('console', expect.objectContaining({ lines: ['saved'] })); + await controller.refresh(); + expect(onError).toHaveBeenLastCalledWith(''); + expect(onData).toHaveBeenLastCalledWith('console', expect.objectContaining({ lines: ['recovered'] })); +}); + +it('rejects malformed successful log payloads before they reach the renderer', async () => { + const { fetcher, onData, onError } = setup(); + fetcher.mockResolvedValue(new Response(JSON.stringify({ ok: true, lines: [42] }))); + controller.configure(target, false, 5000); + await controller.refresh(); + expect(onData).not.toHaveBeenCalled(); + expect(onError).toHaveBeenLastCalledWith('Invalid log response: expected an array of lines.'); +}); diff --git a/tests/unit/ui-server-detail.test.ts b/tests/unit/ui-server-detail.test.ts new file mode 100644 index 0000000..0c0a596 --- /dev/null +++ b/tests/unit/ui-server-detail.test.ts @@ -0,0 +1,98 @@ +import { expect, it, vi } from 'vitest'; +import { render } from 'svelte/server'; +import { writable } from 'svelte/store'; +import { get } from 'svelte/store'; +import type { ComponentProps } from 'svelte'; +import type { GameTelemetry } from '../../src/lib/telemetry.js'; +import type { FleetOperation } from '../../src/lib/fleet-overview.js'; +import ServerPage from '../../src/routes/servers/[name]/+page.svelte'; +import PlayerScoreboard from '../../src/lib/ui/components/PlayerScoreboard.svelte'; +import ConfigInspector from '../../src/lib/ui/components/ConfigInspector.svelte'; +import { uiNow } from '../../src/lib/ui/stores/servers.js'; +import { instanceActivity } from '../../src/lib/ui/server-activity.js'; +import { gameEvent } from '../helpers/telemetry.js'; +import { page } from '$app/stores'; + +vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/servers/t5_zombie_theater_1') } })); +vi.mock('$app/stores', () => ({ page: writable({ params: { name: 't5_zombie_theater_1' }, url: new URL('http://localhost/servers/t5_zombie_theater_1') }) })); +vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), afterNavigate: vi.fn(), goto: vi.fn() })); + +const report = (current = true): GameTelemetry => { + const receivedAt = new Date(get(uiNow) - (current ? 10_000 : 300_000)).toISOString(); + const joined = new Date(Date.parse(receivedAt) - 60_000).toISOString(); + return { ...gameEvent('heartbeat', 4, 1), receivedAt, timing: { matchId: 1, players: { + '12345678901234560': { serverJoinedAt: joined, matchJoinedAt: joined, roundStarted: 1 } + } } }; +}; + +it('keeps scoreboard columns focused and collapses player identifiers and timings', () => { + const body = render(PlayerScoreboard, { props: { report: report(), matchElapsed: '4m 19s' } }).body; + expect(body).toContain('Live scoreboard'); + expect(body).toContain('Round 4'); + expect(body).toContain('Match 4m 19s'); + expect(body).toContain('aria-expanded="false"'); + expect(body).toMatch(/]*hidden/); + expect(body).toContain('Time on instance: 1m 10s'); + expect(body).toContain('aria-label="Copy GUID for Player 1"'); + expect(body).not.toContain('>Time on server'); + expect(body).not.toContain('>Round started'); + expect(body).toContain('aria-sort="descending"'); + expect(body).toContain('About these stats'); +}); + +it('freezes historical timing and keeps unreported scores distinct from zero', () => { + const body = render(PlayerScoreboard, { props: { report: report(false) } }).body; + expect(body).toContain('Last reported scoreboard'); + expect(body).toContain('in last report'); + expect(body).toContain('Time on instance: 1m 00s'); + expect(body).not.toContain('Time on instance: 6m'); + const start = { ...gameEvent('game_start', 0, 1), receivedAt: report().receivedAt }; + expect(render(PlayerScoreboard, { props: { report: start } }).body).toContain('aria-label="Not yet reported"'); +}); + +it('omits the fixed instance selector while keeping promoted/live distinction', () => { + const body = render(ConfigInspector, { props: { fixedServer: 't5_zombie_theater_1' } }).body; + expect(body).not.toContain(' = {}): FleetOperation => ({ key: 'deployment:1', group: 'deployment:1', server: 'one', kind: 'deployment', label: 'Complete', activityLabel: 'Restart instance', resultState: 'cancelled', detail: 'Checking game status', href: '/provisioning/1', state: 'done', startedAt: '2026-10-06T12:00:00Z', ...changes }); +it('merges duplicate campaign records, retains provisioning and preserves the real outcome', () => { + const rows = instanceActivity('one', [op(), op({ key: 'campaign:x:one', kind: 'campaign', href: '/campaigns/x' }), op({ key: 'other', server: 'two' })], [{ + id: 'x', action: 'promote', started_at: '2026-10-06T13:00:00Z', servers: [{ server: 'one', restart_state: 'failed', error: 'Restart failed', files_changed: 1 }] + }]); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ label: 'Promote files · 1 file', state: 'failed', detail: 'Restart failed' }); + expect(rows[1]).toMatchObject({ label: 'Restart instance', state: 'cancelled', href: '/provisioning/1' }); +}); + +function pageBody(tab: string) { + (page as unknown as ReturnType).set({ params: { name: 't5_zombie_theater_1' }, url: new URL(`http://localhost/servers/t5_zombie_theater_1?tab=${tab}&returnTo=%2F%3Ffilter%3Doccupied`) }); + const telemetry = report(); + const server = { name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater', health: 'online', last_players: 1, + last_seen_at: telemetry.receivedAt, last_error: null, held: [], telemetry, status_source: 'telemetry', host_id: 'host-1', native_instance_id: 'instance-1', + host: { name: 'ec-1', connected: true, lastHeartbeat: telemetry.receivedAt }, + rcon_status: { last_seen_at: null, last_error: 'timeout: no reply' }, game_started_at: null, game_ended_at: null, active_since: null, inactive_since: null }; + const data = { serverSnapshot: { servers: [server], operations: [], serverNow: get(uiNow), integrations: {}, reservedNames: [] } } as unknown as ComponentProps['data']; + return render(ServerPage, { props: { data } }).body; +} +it('keeps match content out of the shared header and preserves the fleet return filter', () => { + const body = pageBody('configuration'); + expect(body).toContain('Instance actions'); + expect(body).toContain('href="/?filter=occupied"'); + expect(body).toContain('ec-1'); + expect(body).not.toContain('Active for'); + expect(body).not.toContain('Round 4'); + expect(body).not.toContain('aria-label="Configuration sections"'); +}); +it('reports RCON failures independently of fresh telemetry and defers the request inspector', () => { + const body = pageBody('diagnostics'); + expect(body).toContain('Game connection · RCON'); + expect(body).toContain('Issue'); + expect(body).toContain('Reporting'); + expect(body).toContain('Player count source: game telemetry'); + expect(body).toContain('Connection details'); + expect(body).not.toContain('Telemetry API requests'); + expect(body).not.toContain('>Healthy<'); +});