diff --git a/README.md b/README.md index fe7cd86..af3c463 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,26 @@ or restarting containers show **Starting**, without old RCON errors or player data. Stopped, unhealthy, or unverifiable containers are not polled. Containers without a Docker health check use their running state as the readiness signal. +## Fleet overview + +Fleet searches server names, map names, and the full player roster. Filter occupied, +empty, unhealthy, testing, or pinned lobbies; switch between cards and a compact +table; and group by game or map. Pins and display preferences are saved in this +browser. Map groups preserve T6 variants and can preselect the exact map when +launching another lobby. + +Health summaries exclude stale player counts. The match spotlight uses fresh round +telemetry from an occupied, ongoing match. Persisted campaign, beta, creation, +removal, and restart-recovery state appears alongside affected servers. Fleet +membership and operation progress refresh every ten seconds, with live server +status between refreshes. Failed refreshes keep the last snapshot visible. + +**Follow lobby** can notify you when a server is freshly confirmed empty, returns +online, or a selected campaign/server operation completes. Watches run while the +app is open, continue across navigation, and survive reloads in the same tab. +Notifications include a link and remain until dismissed. Watches do not perform +server actions or send browser push notifications after the app closes. + ## Stack - SvelteKit (adapter-node) + TypeScript diff --git a/src/lib/fleet-overview.ts b/src/lib/fleet-overview.ts new file mode 100644 index 0000000..826202d --- /dev/null +++ b/src/lib/fleet-overview.ts @@ -0,0 +1,107 @@ +import { parseServerName, zombieMapBySlug } from './map-aliases.js'; +import type { ZombieGame } from './map-aliases.js'; +import type { RoundTelemetry } from './telemetry.js'; +import type { Health, RosterPlayer } from './ui/stores/servers.js'; +import { mapTitle, serverTitle } from './ui/server-labels.js'; + +export interface FleetServer { + name: string; + game: string | null; + map: string | null; + map_slug?: string | null; + game_mode?: 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: { target: string; betaId: number }[]; + active_since: string | null; + inactive_since: string | null; + game_started_at: string | null; + game_ended_at: string | null; +} + +export interface FleetOperation { + key: string; + group: string; + server: string; + kind: 'campaign' | 'deployment' | 'beta' | 'recovery'; + label: string; + detail: string; + href: string; + state: 'active' | 'attention' | 'done'; + startedAt: string; +} + +export type FleetFilter = 'all' | 'occupied' | 'empty' | 'attention' | 'testing' | 'pinned'; +export type FleetSort = 'name' | 'players' | 'round' | 'duration'; + +export function freshServer(server: FleetServer, now: number): boolean { + const age = now - Date.parse(server.last_seen_at ?? ''); + return server.health === 'online' && age >= 0 && age <= 30_000; +} + +export function fleetIdentity(server: FleetServer) { + const parsed = parseServerName(server.name); + const game = server.game ?? parsed?.game ?? ''; + const mode = server.game_mode ?? parsed?.gameMode; + const selection = !mode || mode === 'zombie' + ? zombieMapBySlug(game, server.map_slug ?? parsed?.mapSlug ?? '') : undefined; + const map = server.last_map ?? server.map; + // A live map change must not keep the registered map's launch shortcut. + const matchingSelection = selection && (!map || selection.canonical === map) ? selection : undefined; + return { + game, + title: serverTitle(server.name), + map: matchingSelection?.friendly ?? mapTitle(map), + group: `${game}:${matchingSelection?.slug ?? map ?? 'unknown'}`, + launch: matchingSelection && (game === 't5' || game === 't6') + ? { game: game as ZombieGame, slug: matchingSelection.slug } : null + }; +} + +export function currentRound(server: FleetServer, report: RoundTelemetry | undefined, now: number): number | null { + if (!report || !freshServer(server, now) || !server.active_since || !server.game_started_at + || server.game_ended_at || (server.last_players ?? 0) === 0) return null; + const received = Date.parse(report.receivedAt); + const age = now - received; + return age >= 0 && age <= 120_000 && received >= Date.parse(server.game_started_at) + && received >= Date.parse(server.active_since) ? report.round : null; +} + +export function matchesFleet(server: FleetServer, query: string, filter: FleetFilter, game: string, + pinned: readonly string[], operations: readonly FleetOperation[], now: number): boolean { + const identity = fleetIdentity(server); + if (game && identity.game !== game) return false; + const fresh = freshServer(server, now); + if (filter === 'occupied' && !(fresh && (server.last_players ?? 0) > 0)) return false; + if (filter === 'empty' && !(fresh && server.last_players === 0)) return false; + if (filter === 'attention' && !(server.health === 'offline' || server.health === 'stale' + || (server.health === 'online' && !fresh) || operations.some(op => op.server === server.name && op.state === 'attention'))) return false; + if (filter === 'testing' && !server.held.length) return false; + if (filter === 'pinned' && !pinned.includes(server.name)) return false; + const text = [server.name, identity.title, identity.map, identity.game, + ...(server.roster ?? []).map(player => player.name)].join(' ').toLocaleLowerCase(); + return query.trim().toLocaleLowerCase().split(/\s+/).every(term => text.includes(term)); +} + +export interface LobbyWatch { + id: string; + server: string; + kind: 'empty' | 'online' | 'operation'; + operationKey?: string; + href: string; +} + +export function watchSatisfied(watch: LobbyWatch, server: FleetServer | undefined, + operations: readonly FleetOperation[], now: number): boolean { + if (watch.kind === 'operation') { + // Disappearance, failure and replacement by another operation are not completion. + return operations.some(op => op.key === watch.operationKey && op.state === 'done'); + } + if (!server || !freshServer(server, now)) return false; + return watch.kind === 'online' || server.last_players === 0; +} diff --git a/src/lib/server/fleet/overview.ts b/src/lib/server/fleet/overview.ts new file mode 100644 index 0000000..747ef03 --- /dev/null +++ b/src/lib/server/fleet/overview.ts @@ -0,0 +1,73 @@ +import { db } from '../db/index.js'; +import type { FleetOperation } from '../../fleet-overview.js'; + +/** Read persisted work, including recent completions for one-shot lobby watches. */ +export function fleetOperations(now = Date.now()): FleetOperation[] { + const since = new Date(now - 86_400_000).toISOString(); + const handle = db(); + const operations: FleetOperation[] = []; + const slots = handle.prepare('SELECT server, requested_at, error FROM restart_rollout WHERE healthy_at IS NULL') + .all() as { server: string; requested_at: string; error: string | null }[]; + const recovering = new Map(slots.map(slot => [slot.server, slot])); + const representedRecovery = new Set(); + const campaigns = handle.prepare(`SELECT d.id, d.action, d.started_at, ds.server, + ds.restart_state, ds.error, ds.restarted_at, ds.players + FROM deployments d JOIN deployment_servers ds ON ds.deployment_id = d.id + WHERE ds.restart_state != 'done' OR ds.checked_at >= ? OR d.finished_at >= ? OR d.started_at >= ?`).all(since, since, since) as { + id: string; action: string; started_at: string; server: string; restart_state: string; + error: string | null; restarted_at: string | null; players: number | null; + }[]; + for (const row of campaigns) { + const state = row.restart_state === 'done' ? 'done' + : ['failed', 'manual'].includes(row.restart_state) ? 'attention' : 'active'; + const slot = recovering.get(row.server); + if (row.restarted_at && state !== 'done') representedRecovery.add(row.server); + const label = state === 'done' ? 'Update complete' : state === 'attention' ? 'Update needs attention' + : row.restart_state === 'held' ? 'Held by a beta' + : row.restarted_at ? 'Recovering' : (row.players ?? 0) > 0 ? 'Waiting for an empty lobby' : 'Queued for restart'; + operations.push({ key: `campaign:${row.id}:${row.server}`, group: `campaign:${row.id}`, + kind: 'campaign', server: row.server, label, state, startedAt: row.started_at, + detail: state === 'done' ? 'Update applied and restart checks completed' : row.error ?? (row.restarted_at ? slot?.error ?? 'Verifying server health' + : (row.players ?? 0) > 0 ? `${row.players} players at the last check` + : 'Waiting for restart eligibility and available capacity'), + href: `/campaigns/${encodeURIComponent(row.id)}` }); + } + // Only the newest lifecycle operation can still require action on a server. + const lifecycle = handle.prepare(`SELECT d.* FROM server_deployments d + WHERE d.state IN ('queued', 'running', 'waiting', 'awaiting-health') + OR (d.state IN ('done', 'warning') AND d.finished_at >= ?) + OR (d.state IN ('failed', 'interrupted', 'warning') AND NOT EXISTS + (SELECT 1 FROM server_deployments n WHERE n.server = d.server AND n.rowid > d.rowid))`) + .all(since) as { id: string; server: string; action: string; state: string; stage: string; + error: string | null; started_at: string }[]; + for (const row of lifecycle) { + const state = row.state === 'done' || row.state === 'warning' ? 'done' + : ['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, + label: state === 'done' ? row.state === 'warning' ? 'Completed with warnings' : 'Complete' + : state === 'attention' ? `${row.action === 'create' ? 'Launch' : 'Removal'} needs attention` : row.stage, + detail: row.error ?? row.stage, href: `/deployments/${encodeURIComponent(row.id)}` }); + } + const betas = handle.prepare(`SELECT b.id, b.opened_at, bs.server, bs.stage, bs.error + FROM betas b JOIN beta_servers bs ON bs.beta_id = b.id WHERE b.state IN ('running', 'ready-promote')`) + .all() as { id: number; opened_at: string; server: string; stage: string; error: string | null }[]; + const stages: Record = { queued: 'Waiting for an earlier test wave', deploying: 'Deploying beta', + 'awaiting-empty': 'Waiting for an empty lobby', restarting: 'Restarting', 'awaiting-health': 'Recovering', + 'awaiting-match': 'Observing a match', ready: 'Ready for review', failed: 'Beta needs attention' }; + for (const row of betas) { + if (['restarting', 'awaiting-health'].includes(row.stage)) representedRecovery.add(row.server); + operations.push({ key: `beta:${row.id}:${row.server}`, group: `beta:${row.id}`, + kind: 'beta', server: row.server, startedAt: row.opened_at, + state: row.stage === 'failed' ? 'attention' : 'active', label: stages[row.stage] ?? row.stage, + detail: row.error ?? recovering.get(row.server)?.error ?? `Beta ${row.id}`, href: `/betas/${row.id}` }); + } + for (const slot of slots) { + if (representedRecovery.has(slot.server)) continue; + operations.push({ key: `recovery:${slot.server}:${slot.requested_at}`, group: `recovery:${slot.server}`, + kind: 'recovery', server: slot.server, startedAt: slot.requested_at, state: slot.error ? 'attention' : 'active', + label: 'Recovering from restart', detail: slot.error ?? 'Verifying server health', + href: `/servers/${encodeURIComponent(slot.server)}` }); + } + return operations.sort((a, b) => b.startedAt.localeCompare(a.startedAt)); +} diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte index 060b781..83812e7 100644 --- a/src/lib/ui/components/DeployServerDialog.svelte +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -6,8 +6,8 @@ import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js'; import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js'; - let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', onblueprint }: - { open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: string; onblueprint?: () => void } = $props(); + let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '', onblueprint }: + { open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string; onblueprint?: () => void } = $props(); let dialog: HTMLDialogElement | undefined = $state(); let game = $state('t5'); let mapSlug = $state(''); @@ -40,6 +40,8 @@ preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; blueprintName = ''; description = ''; savedName = ''; saving = false; blueprintId = initialBlueprintId; + game = initialGame; + mapSlug = initialMapSlug; try { const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]); if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and blueprints. Try again.'); diff --git a/src/lib/ui/components/RoundTelemetry.svelte b/src/lib/ui/components/RoundTelemetry.svelte index 3a5cd20..6941926 100644 --- a/src/lib/ui/components/RoundTelemetry.svelte +++ b/src/lib/ui/components/RoundTelemetry.svelte @@ -1,11 +1,14 @@ -{#if report} +{#if report && fresh}
Round {report.round} Last reported {elapsedSince(report.receivedAt, $uiNow)} ago · {report.players} players diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index 6b78431..b179a85 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -30,6 +30,8 @@ inactiveSince?: string | null; gameStartedAt?: string | null; gameEndedAt?: string | null; + compactEmpty?: boolean; + mapLabelOverride?: string; } let { name, @@ -46,7 +48,9 @@ activeSince = null, inactiveSince = null, gameStartedAt = null, - gameEndedAt = null + gameEndedAt = null, + compactEmpty = false, + mapLabelOverride }: Props = $props(); function ago(iso: string | null): string { @@ -66,7 +70,7 @@ ); let showRoster = $derived(health === 'online' || health === 'stale'); let title = $derived(serverTitle(name)); - let mapLabel = $derived(mapTitle(map)); + let mapLabel = $derived(mapLabelOverride ?? mapTitle(map)); let gtLabel = $derived(gametypeLabel(gametype)); let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null); @@ -106,7 +110,7 @@ {:else}
0 ? 'online' : 'primary'} tween @@ -123,9 +127,9 @@ {/if}
- {#if showRoster}{/if} + {#if health === 'online' && players !== null && players > 0 && gameInProgress}{/if} - {#if showTiming} + {#if showTiming && !(compactEmpty && players === 0)}
{#if activeElapsed}
@@ -159,7 +163,7 @@ {/each} {:else} -
Lobby is empty
+
{health === 'stale' ? 'Waiting for a fresh report' : players === 0 ? 'Lobby is empty' : 'Player roster unavailable'}
{/if} {/if} diff --git a/src/lib/ui/components/Toaster.svelte b/src/lib/ui/components/Toaster.svelte index 4514fb0..d204fd7 100644 --- a/src/lib/ui/components/Toaster.svelte +++ b/src/lib/ui/components/Toaster.svelte @@ -4,7 +4,7 @@ import { toasts, dismiss } from '$ui/toast.js'; -
+
{#each $toasts as t (t.id)}
{t.message} + {#if t.href} dismiss(t.id)}>View{/if} + + {/snippet} + - + {#if $fleetError} + + {/if} - {#if servers.length === 0} - + {#if !$fleet.receivedAt} + {#if !$fleetError}
Loading your fleet…
{/if} {:else} -
- {#each servers as s, i (s.name)} - {@const live = liveFor(s.name, s)} -
- -
- {/each} +
+
0 ? 'online' : 'primary'} tween />From fresh server reports
+
{starting ? `${starting} starting` : 'Live fleet status'}
+ + View progress →
+ + {#if servers.length || activeOperations.length} +
0 || attentionOperations.length > 0}> + +
+ {#if problems.length} + + {:else}{servers.length ? starting ? `${starting} ${starting === 1 ? 'server is' : 'servers are'} starting` : 'All servers healthy' : 'Preparing your fleet'}{/if} + {problems.length ? 'Open a server to inspect its connection and diagnostics.' : 'Live health, match reports, and operation progress in one place.'} +
+ {#if attentionOperations.length} operationsOpen = true}>{attentionOperations.length} operation {attentionOperations.length === 1 ? 'needs' : 'need'} attention →{/if} +
+ {/if} + + {#if spotlight} + +
Longest running match{serverTitle(spotlight.name)}
+ Round {currentRound(spotlight, $telemetryByServer.get(spotlight.name), $uiNow)} + {spotlight.last_players} {spotlight.last_players === 1 ? 'survivor' : 'survivors'} · {elapsedSince(spotlight.game_started_at, $uiNow)} +
+ {/if} + + {#if $lobbyWatches.length} +
+
Following {$lobbyWatches.length} {$lobbyWatches.length === 1 ? 'condition' : 'conditions'}Notifications stay active as you navigate. Keep this app open.
+ {#each $lobbyWatches as item (item.id)} + + {/each} +
+ {/if} + + {#if activeOperations.length} +
+ {activeOperations.length} server {activeOperations.length === 1 ? 'operation' : 'operations'} in progress{attentionOperations.length ? ` · ${attentionOperations.length} need attention` : ''} +
{#each activeOperations as operation (operation.key)} + {serverTitle(operation.server)}{operation.label}{operation.detail} + {/each}
+
+ {/if} + + {#if servers.length === 0} + + {:else} +
+
+ + + + +
+
+
{#each filters as item} + + {/each}
+
+
+
Showing {filtered.length} of {servers.length} servers{#if query || filter !== 'all' || game}{/if}Pinned lobbies appear first in each group.
+
+ + {#if filtered.length === 0}{/if} + {#each groups as [key, group] (key)} +
+
{grouping === 'map' ? group.game.toUpperCase() : 'Fleet lineup'}

{group.title}

{group.servers.length} {group.servers.length === 1 ? 'lobby' : 'lobbies'} · {group.servers.reduce((sum, s) => sum + (freshServer(s, $uiNow) ? s.last_players ?? 0 : 0), 0)} players · {group.servers.filter(s => freshServer(s, $uiNow) && s.last_players === 0).length} empty

+ {#if group.launch}{/if} +
+ {#if view === 'cards'} +
{#each group.servers as server (server.name)} +
+ + {@render serverOperations(server)} +
{@render serverActions(server)}
+
+ {/each}
+ {:else} +
+ {#each group.servers as server (server.name)} + {@const round = currentRound(server, $telemetryByServer.get(server.name), $uiNow)} + + + + + {/each} +
{group.title} servers
LobbyStatusPlayersRound / matchOperationFollow
{serverTitle(server.name)}{fleetIdentity(server).map}{server.health}{freshServer(server, $uiNow) ? server.last_players ?? '—' : '—'}{round !== null ? `Round ${round}` : '—'}{#if matchDuration(server) >= 0}{elapsedSince(server.game_started_at, $uiNow)}{/if}{@render serverOperations(server, true)}
{@render serverActions(server)}
+ {/if} +
+ {/each} + {/if} {/if} +{#snippet serverOperations(server: FleetServer, showEmpty = false)} + {@const operations = activeOperations.filter(op => op.server === server.name)} + {#if operations.length}
{#each operations as operation (operation.key)} + {operation.label} →{operation.detail} + {/each}
{:else if showEmpty}No pending work{/if} +{/snippet} + +{#snippet serverActions(server: FleetServer)} + + +{/snippet} + diff --git a/src/routes/api/fleet/+server.ts b/src/routes/api/fleet/+server.ts new file mode 100644 index 0000000..fc9f7d9 --- /dev/null +++ b/src/routes/api/fleet/+server.ts @@ -0,0 +1,6 @@ +import { json } from '@sveltejs/kit'; +import { fleetOperations } from '$lib/server/fleet/overview.js'; + +export function GET() { + return json({ operations: fleetOperations() }); +} diff --git a/tests/unit/fleet-overview.test.ts b/tests/unit/fleet-overview.test.ts new file mode 100644 index 0000000..21b8a66 --- /dev/null +++ b/tests/unit/fleet-overview.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { currentRound, fleetIdentity, matchesFleet, watchSatisfied, type FleetServer, type FleetOperation } from '../../src/lib/fleet-overview.js'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +const now = Date.parse('2026-09-18T12:00:00Z'); +const iso = (offset = 0) => new Date(now + offset).toISOString(); +const server: FleetServer = { + name: 't6_zombie_town_1', game: 't6', map: 'zm_transit', last_map: 'zm_transit', + last_players: 4, last_gametype: 'zstandard', last_seen_at: iso(-1000), last_error: null, + roster: ['Alex', 'Blake', 'Casey', 'Josh'].map((name, i) => ({ name, num: i, score: 100 - i, ping: 30, guid: `${i}` })), + health: 'online', held: [], active_since: iso(-120_000), inactive_since: null, + game_started_at: iso(-60_000), game_ended_at: null +}; +const operation: FleetOperation = { key: 'campaign:one:server', group: 'campaign:one', server: server.name, + kind: 'campaign', label: 'Queued', detail: '', href: '/campaigns/one', state: 'active', startedAt: iso() }; + +describe('Fleet discovery and match freshness', () => { + it('finds a player outside the displayed top three, including with a map query', () => { + expect(matchesFleet(server, 'josh town', 'all', '', [], [], now)).toBe(true); + expect(matchesFleet(server, 'josh farm', 'all', '', [], [], now)).toBe(false); + }); + it('keeps T6 variants distinct and launches the exact selection', () => { + const town = fleetIdentity(server); + const farm = fleetIdentity({ ...server, name: 't6_zombie_farm_1' }); + expect(town.map).toBe('Town Survival'); + expect(town.group).not.toBe(farm.group); + expect(town.launch).toEqual({ game: 't6', slug: 'town' }); + expect(fleetIdentity({ ...server, name: 'custom', map_slug: 'town' }).launch).toEqual(town.launch); + expect(fleetIdentity({ ...server, last_map: 'zm_tomb' }).launch).toBeNull(); + expect(fleetIdentity({ ...server, game_mode: 'mp' }).launch).toBeNull(); + }); + it('does not classify stale, missing, or starting occupancy as an empty lobby', () => { + for (const change of [{ last_seen_at: iso(-31_000) }, { last_players: null }, { health: 'starting' as const }, { health: 'offline' as const }]) { + expect(matchesFleet({ ...server, last_players: 0, ...change }, '', 'empty', '', [], [], now)).toBe(false); + } + expect(matchesFleet({ ...server, last_players: 0 }, '', 'empty', '', [], [], now)).toBe(true); + expect(matchesFleet(server, '', 'occupied', 't5', [], [], now)).toBe(false); + }); + it('includes stale health and failed operations in attention without flagging normal startup', () => { + expect(matchesFleet({ ...server, last_seen_at: iso(-31_000) }, '', 'attention', '', [], [], now)).toBe(true); + expect(matchesFleet(server, '', 'attention', '', [], [{ ...operation, state: 'attention' }], now)).toBe(true); + expect(matchesFleet({ ...server, health: 'starting' }, '', 'attention', '', [], [], now)).toBe(false); + }); + it('only spotlights fresh telemetry from the current occupied match', () => { + const report = { server: server.name, round: 38, players: 4, receivedAt: iso(-1000) }; + expect(currentRound(server, report, now)).toBe(38); + for (const change of [{ receivedAt: iso(-121_000) }, { receivedAt: iso(-61_000) }, { receivedAt: iso(60_000) }]) { + expect(currentRound(server, { ...report, ...change }, now)).toBeNull(); + } + for (const change of [{ game_ended_at: iso() }, { last_players: 0 }, { health: 'stale' as const }, { active_since: iso() }]) { + expect(currentRound({ ...server, ...change }, report, now)).toBeNull(); + } + }); +}); + +describe('one-shot lobby watches', () => { + it('requires a fresh zero-player reading, never an absent server or unknown count', () => { + const watch = { id: 'empty', server: server.name, kind: 'empty' as const, href: '/' }; + expect(watchSatisfied(watch, { ...server, last_players: 0 }, [], now)).toBe(true); + expect(watchSatisfied(watch, undefined, [], now)).toBe(false); + expect(watchSatisfied(watch, { ...server, last_players: null }, [], now)).toBe(false); + expect(watchSatisfied(watch, { ...server, last_players: 0, last_seen_at: iso(-31_000) }, [], now)).toBe(false); + }); + it('only completes the watched operation on explicit success', () => { + const watch = { id: 'op', server: server.name, kind: 'operation' as const, operationKey: operation.key, href: '/' }; + expect(watchSatisfied(watch, server, [], now)).toBe(false); + expect(watchSatisfied(watch, server, [{ ...operation, state: 'attention' }], now)).toBe(false); + expect(watchSatisfied(watch, server, [{ ...operation, key: 'different', state: 'done' }], now)).toBe(false); + expect(watchSatisfied(watch, undefined, [{ ...operation, state: 'done' }], now)).toBe(true); + }); +}); + +describe('persisted Fleet operations', () => { + let fixture: Fixture; + beforeEach(async () => { vi.resetModules(); fixture = await newFixture(); }); + afterEach(async () => { await fixture.cleanup(); }); + + it('recovers waiting and recovering campaigns without needing a live event', async () => { + const { db, closeDb } = await import('../../src/lib/server/db/index.js'); + const handle = db(); + handle.prepare("INSERT INTO servers (name, komodo_name) VALUES ('server', 'server')").run(); + handle.prepare("INSERT INTO deployments (id, action, started_at) VALUES ('one', 'promote', ?)").run(iso(-2 * 86_400_000)); + handle.prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, players) VALUES ('one', 'server', 1, 'pending', 4)").run(); + const { fleetOperations } = await import('../../src/lib/server/fleet/overview.js'); + expect(fleetOperations(now)).toEqual([expect.objectContaining({ label: 'Waiting for an empty lobby', state: 'active', href: '/campaigns/one' })]); + handle.prepare("UPDATE deployment_servers SET restarted_at = ?, players = NULL").run(iso()); + handle.prepare("INSERT INTO restart_rollout (server, requested_at) VALUES ('server', ?)").run(iso()); + expect(fleetOperations(now)).toHaveLength(1); + expect(fleetOperations(now)[0].label).toBe('Recovering'); + closeDb(); + expect(fleetOperations(now)[0].label).toBe('Recovering'); + db().prepare("UPDATE deployment_servers SET restart_state = 'done', checked_at = ?").run(iso()); + db().prepare('UPDATE restart_rollout SET healthy_at = ?').run(iso()); + expect(fleetOperations(now)[0].state).toBe('done'); + }); + + it('keeps lifecycle failures actionable until superseded and retains successful completions for watches', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + const handle = db(); + const insert = handle.prepare("INSERT INTO server_deployments (id, server, action, state, stage, started_at, finished_at, triggered_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'test')"); + insert.run('failed', 'server', 'create', 'failed', 'Creating container', iso(-86_400_001), iso(-86_400_001)); + const { fleetOperations } = await import('../../src/lib/server/fleet/overview.js'); + expect(fleetOperations(now)[0].state).toBe('attention'); + insert.run('retry', 'server', 'create', 'done', 'Ready', iso(), iso()); + expect(fleetOperations(now)).toEqual([expect.objectContaining({ key: 'deployment:retry', state: 'done' })]); + insert.run('queued', 'new-server', 'create', 'queued', 'Waiting to launch', iso(), null); + expect(fleetOperations(now)).toContainEqual(expect.objectContaining({ server: 'new-server', label: 'Waiting to launch', state: 'active' })); + }); + + it('surfaces orphaned recovery errors and beta gates', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + const handle = db(); + handle.prepare("INSERT INTO servers (name, komodo_name) VALUES ('server', 'server')").run(); + handle.prepare("INSERT INTO restart_rollout (server, requested_at, error) VALUES ('server', ?, 'RCON not answering')").run(iso()); + const { fleetOperations } = await import('../../src/lib/server/fleet/overview.js'); + expect(fleetOperations(now)[0]).toMatchObject({ kind: 'recovery', state: 'attention', detail: 'RCON not answering' }); + handle.prepare("INSERT INTO scripts (id, name, target_rel_path) VALUES (1, 'test', 'test.gsc')").run(); + handle.prepare("INSERT INTO script_versions (id, script_id, version, sha256, size, content) VALUES (1, 1, 1, 'hash', 0, X'')").run(); + handle.prepare("INSERT INTO betas (id, script_id, script_version_id, trigger_kind, state) VALUES (1, 1, 1, 'ui', 'running')").run(); + handle.prepare("INSERT INTO beta_servers (beta_id, server, had_original, stage) VALUES (1, 'server', 0, 'awaiting-match')").run(); + expect(fleetOperations(now)).toContainEqual(expect.objectContaining({ kind: 'beta', label: 'Observing a match', href: '/betas/1' })); + expect(fleetOperations(now)).toContainEqual(expect.objectContaining({ kind: 'recovery', state: 'attention' })); + }); +});