Refresh Fleet with discovery, operation progress, and lobby watches

This commit is contained in:
2026-09-18 11:59:14 -04:00
parent ef8ff69d17
commit 9cf0bd87f4
14 changed files with 761 additions and 240 deletions
+20
View File
@@ -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 data. Stopped, unhealthy, or unverifiable containers are not polled. Containers
without a Docker health check use their running state as the readiness signal. 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 ## Stack
- SvelteKit (adapter-node) + TypeScript - SvelteKit (adapter-node) + TypeScript
+107
View File
@@ -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;
}
+73
View File
@@ -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<string>();
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<string, string> = { 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));
}
@@ -6,8 +6,8 @@
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js'; import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js'; import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', onblueprint }: let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '', onblueprint }:
{ open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: string; onblueprint?: () => void } = $props(); { open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string; onblueprint?: () => void } = $props();
let dialog: HTMLDialogElement | undefined = $state(); let dialog: HTMLDialogElement | undefined = $state();
let game = $state<ZombieGame>('t5'); let game = $state<ZombieGame>('t5');
let mapSlug = $state(''); let mapSlug = $state('');
@@ -40,6 +40,8 @@
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; preview = null; plutoKey = ''; error = ''; loaded = false; busy = false;
blueprintName = ''; description = ''; savedName = ''; saving = false; blueprintName = ''; description = ''; savedName = ''; saving = false;
blueprintId = initialBlueprintId; blueprintId = initialBlueprintId;
game = initialGame;
mapSlug = initialMapSlug;
try { try {
const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]); 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.'); if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and blueprints. Try again.');
+5 -2
View File
@@ -1,11 +1,14 @@
<script lang="ts"> <script lang="ts">
import { telemetryByServer, uiNow } from '$ui/stores/servers.js'; import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
import { elapsedSince } from '$ui/duration.js'; import { elapsedSince } from '$ui/duration.js';
let { name }: { name: string } = $props(); let { name, since = null }: { name: string; since?: string | null } = $props();
let report = $derived($telemetryByServer.get(name)); let report = $derived($telemetryByServer.get(name));
let fresh = $derived(report && $uiNow - Date.parse(report.receivedAt) >= 0
&& $uiNow - Date.parse(report.receivedAt) <= 120_000
&& (!since || Date.parse(report.receivedAt) >= Date.parse(since)));
</script> </script>
{#if report} {#if report && fresh}
<div class="round" aria-live="polite"> <div class="round" aria-live="polite">
<strong>Round {report.round}</strong> <strong>Round {report.round}</strong>
<span title={report.receivedAt}>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago · {report.players} players</span> <span title={report.receivedAt}>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago · {report.players} players</span>
+10 -6
View File
@@ -30,6 +30,8 @@
inactiveSince?: string | null; inactiveSince?: string | null;
gameStartedAt?: string | null; gameStartedAt?: string | null;
gameEndedAt?: string | null; gameEndedAt?: string | null;
compactEmpty?: boolean;
mapLabelOverride?: string;
} }
let { let {
name, name,
@@ -46,7 +48,9 @@
activeSince = null, activeSince = null,
inactiveSince = null, inactiveSince = null,
gameStartedAt = null, gameStartedAt = null,
gameEndedAt = null gameEndedAt = null,
compactEmpty = false,
mapLabelOverride
}: Props = $props(); }: Props = $props();
function ago(iso: string | null): string { function ago(iso: string | null): string {
@@ -66,7 +70,7 @@
); );
let showRoster = $derived(health === 'online' || health === 'stale'); let showRoster = $derived(health === 'online' || health === 'stale');
let title = $derived(serverTitle(name)); let title = $derived(serverTitle(name));
let mapLabel = $derived(mapTitle(map)); let mapLabel = $derived(mapLabelOverride ?? mapTitle(map));
let gtLabel = $derived(gametypeLabel(gametype)); let gtLabel = $derived(gametypeLabel(gametype));
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null); let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
@@ -106,7 +110,7 @@
{:else} {:else}
<div class="players" aria-live="polite"> <div class="players" aria-live="polite">
<Stat <Stat
value={players ?? 0} value={players ?? '—'}
size="md" size="md"
tone={players !== null && players > 0 ? 'online' : 'primary'} tone={players !== null && players > 0 ? 'online' : 'primary'}
tween tween
@@ -123,9 +127,9 @@
{/if} {/if}
</div> </div>
{#if showRoster}<RoundTelemetry {name} />{/if} {#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} since={gameStartedAt} />{/if}
{#if showTiming} {#if showTiming && !(compactEmpty && players === 0)}
<div class="timing"> <div class="timing">
{#if activeElapsed} {#if activeElapsed}
<div class="metric"> <div class="metric">
@@ -159,7 +163,7 @@
{/each} {/each}
</ul> </ul>
{:else} {:else}
<div class="lobby-empty">Lobby is empty</div> <div class="lobby-empty">{health === 'stale' ? 'Waiting for a fresh report' : players === 0 ? 'Lobby is empty' : 'Player roster unavailable'}</div>
{/if} {/if}
{/if} {/if}
+2 -1
View File
@@ -4,7 +4,7 @@
import { toasts, dismiss } from '$ui/toast.js'; import { toasts, dismiss } from '$ui/toast.js';
</script> </script>
<div class="toaster" role="region" aria-label="Notifications"> <div class="toaster" role="region" aria-label="Notifications" aria-live="polite">
{#each $toasts as t (t.id)} {#each $toasts as t (t.id)}
<div <div
class="toast v-{t.variant}" class="toast v-{t.variant}"
@@ -13,6 +13,7 @@
> >
<span class="dot" aria-hidden="true"></span> <span class="dot" aria-hidden="true"></span>
<span class="msg">{t.message}</span> <span class="msg">{t.message}</span>
{#if t.href}<a href={t.href} onclick={() => dismiss(t.id)}>View</a>{/if}
<button <button
type="button" type="button"
class="close" class="close"
+107
View File
@@ -0,0 +1,107 @@
import { derived, get, writable } from 'svelte/store';
import { browser } from '$app/environment';
import { statusByServer, statusReceivedAt } from './servers.js';
import { watchSatisfied, type FleetServer, type FleetOperation, type LobbyWatch } from '$lib/fleet-overview.js';
import { serverTitle } from '../server-labels.js';
import { toast } from '../toast.js';
export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperation[]; receivedAt: number }>({
servers: [], operations: [], receivedAt: 0
});
export const fleetError = writable('');
export const fleetRefreshing = writable(false);
export const lobbyWatches = writable<LobbyWatch[]>([]);
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt], ([snapshot, live, received]) =>
snapshot.servers.map(server => {
const status = received > snapshot.receivedAt ? live.get(server.name) : undefined;
return status ? { ...server, last_players: status.players, last_map: status.map,
last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError,
roster: status.roster, health: status.health, active_since: status.activeSince,
inactive_since: status.inactiveSince, game_started_at: status.gameStartedAt,
game_ended_at: status.gameEndedAt } : server;
}));
let inFlight: Promise<void> | null = null;
export function refreshFleet(): Promise<void> {
if (!browser) return Promise.resolve();
if (inFlight) return inFlight;
inFlight = (async () => {
fleetRefreshing.set(true);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12_000);
const requestedAt = Date.now();
try {
const responses = await Promise.all([
fetch('/api/servers', { signal: controller.signal }),
fetch('/api/fleet', { signal: controller.signal })
]);
if (responses.some(response => !response.ok)) throw new Error('Could not refresh Fleet. Retrying automatically.');
const [servers, operations] = await Promise.all(responses.map(response => response.json()));
fleetError.set('');
fleet.set({ servers: servers.servers, operations: operations.operations, receivedAt: requestedAt });
} catch (error) {
fleetError.set(error instanceof Error && error.name !== 'AbortError'
? error.message : 'Fleet refresh timed out. Retrying automatically.');
} finally {
clearTimeout(timeout);
fleetRefreshing.set(false);
}
})().finally(() => { inFlight = null; });
return inFlight;
}
export function followLobby(server: string, kind: LobbyWatch['kind'], operation?: FleetOperation): void {
const id = `${server}:${kind}:${operation?.key ?? ''}`;
lobbyWatches.update(watches => watches.some(watch => watch.id === id) ? watches : [...watches, {
id, server, kind, operationKey: operation?.key,
href: operation?.href ?? `/servers/${encodeURIComponent(server)}`
}]);
toast('Watching this lobby while the app is open.');
}
export function cancelWatch(id: string): void {
lobbyWatches.update(watches => watches.filter(watch => watch.id !== id));
}
let stop: (() => void) | null = null;
export function startFleetMonitor(): () => void {
if (!browser || stop) return () => {};
try {
const saved: unknown = JSON.parse(sessionStorage.getItem('electric-cherry:lobby-watches') ?? '[]');
if (Array.isArray(saved)) lobbyWatches.set(saved.filter((watch): watch is LobbyWatch =>
watch && typeof watch.id === 'string' && typeof watch.server === 'string'
&& ['empty', 'online', 'operation'].includes(watch.kind)
&& typeof watch.href === 'string' && /^\/(servers|campaigns|deployments)\//.test(watch.href)
&& (watch.kind !== 'operation' || typeof watch.operationKey === 'string')));
} catch { /* Watches still work with session storage disabled. */ }
const stopSaving = lobbyWatches.subscribe(watches => {
try { sessionStorage.setItem('electric-cherry:lobby-watches', JSON.stringify(watches)); } catch { /* Optional storage. */ }
});
void refreshFleet();
const interval = setInterval(() => void refreshFleet(), 10_000);
const onFocus = () => { if (document.visibilityState === 'visible') void refreshFleet(); };
document.addEventListener('visibilitychange', onFocus);
const check = () => {
// Never complete a watch using an operation snapshot from a failed refresh.
if (get(fleetError)) return;
const snapshot = get(fleet);
if (!snapshot.receivedAt || Date.now() - snapshot.receivedAt > 30_000) return;
const servers = get(fleetServers);
for (const watch of get(lobbyWatches)) {
if (!watchSatisfied(watch, servers.find(server => server.name === watch.server), snapshot.operations, Date.now())) continue;
const message = `${serverTitle(watch.server)} ${watch.kind === 'empty' ? 'is empty' : watch.kind === 'online' ? 'is back online' : 'has finished its operation'}.`;
cancelWatch(watch.id);
toast(message, { variant: 'success', ttl: 0, href: watch.href });
}
};
const unsubscribe = fleetServers.subscribe(check);
stop = () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', onFocus);
unsubscribe();
stopSaving();
stop = null;
};
return stop;
}
+2
View File
@@ -39,6 +39,7 @@ export interface ServerStatus {
} }
export const statusByServer = writable<Map<string, ServerStatus>>(new Map()); export const statusByServer = writable<Map<string, ServerStatus>>(new Map());
export const statusReceivedAt = writable(0);
// Shared 1s ticker for elapsed-time displays. One interval feeds every card // Shared 1s ticker for elapsed-time displays. One interval feeds every card
// on the page instead of N per-card timers. Timer starts on the first // on the page instead of N per-card timers. Timer starts on the first
@@ -67,6 +68,7 @@ export function startServerStream(): void {
for (const e of data.entries) m.set(e.server, e); for (const e of data.entries) m.set(e.server, e);
return m; return m;
}); });
statusReceivedAt.set(Date.now());
}); });
if (tickInterval === null) { if (tickInterval === null) {
tickInterval = setInterval(() => _uiNow.set(Date.now()), 1000); tickInterval = setInterval(() => _uiNow.set(Date.now()), 1000);
+4 -2
View File
@@ -14,6 +14,7 @@ export interface Toast {
message: string; message: string;
variant: ToastVariant; variant: ToastVariant;
ttl: number; ttl: number;
href?: string;
} }
export const toasts = writable<Toast[]>([]); export const toasts = writable<Toast[]>([]);
@@ -22,13 +23,14 @@ let nextId = 1;
export function toast( export function toast(
message: string, message: string,
opts: { variant?: ToastVariant; ttl?: number } = {} opts: { variant?: ToastVariant; ttl?: number; href?: string } = {}
): void { ): void {
const t: Toast = { const t: Toast = {
id: nextId++, id: nextId++,
message, message,
variant: opts.variant ?? 'info', variant: opts.variant ?? 'info',
ttl: opts.ttl ?? 3500 ttl: opts.ttl ?? 3500,
href: opts.href
}; };
toasts.update((arr) => [...arr, t]); toasts.update((arr) => [...arr, t]);
if (t.ttl > 0) { if (t.ttl > 0) {
+3
View File
@@ -3,6 +3,7 @@
import { afterNavigate, onNavigate, goto } from '$app/navigation'; import { afterNavigate, onNavigate, goto } from '$app/navigation';
import { startPipelineStream } from '$ui/stores/pipeline.js'; import { startPipelineStream } from '$ui/stores/pipeline.js';
import { startServerStream } from '$ui/stores/servers.js'; import { startServerStream } from '$ui/stores/servers.js';
import { startFleetMonitor } from '$ui/stores/fleet.js';
import { page } from '$app/stores'; import { page } from '$app/stores';
import BrandMark from '$ui/components/BrandMark.svelte'; import BrandMark from '$ui/components/BrandMark.svelte';
import Toaster from '$ui/components/Toaster.svelte'; import Toaster from '$ui/components/Toaster.svelte';
@@ -28,6 +29,7 @@
onMount(() => { onMount(() => {
startPipelineStream(); startPipelineStream();
startServerStream(); startServerStream();
const stopFleetMonitor = startFleetMonitor();
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` deployments, // Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` deployments,
// `g b` betas, `g s` scripts. Ignored while typing in inputs. // `g b` betas, `g s` scripts. Ignored while typing in inputs.
@@ -61,6 +63,7 @@
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => { return () => {
stopFleetMonitor();
window.removeEventListener('keydown', onKey); window.removeEventListener('keydown', onKey);
if (armTimer) clearTimeout(armTimer); if (armTimer) clearTimeout(armTimer);
}; };
+294 -227
View File
@@ -1,257 +1,324 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import ServerCard from '$ui/components/ServerCard.svelte'; import ServerCard from '$ui/components/ServerCard.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte'; import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import { statusByServer } from '$ui/stores/servers.js';
import { deployments } from '$ui/stores/pipeline.js';
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import Stat from '$ui/components/primitives/Stat.svelte'; import Stat from '$ui/components/primitives/Stat.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Card from '$ui/components/primitives/Card.svelte'; import StatusPip from '$ui/components/primitives/StatusPip.svelte';
import { fleet, fleetServers, fleetError, fleetRefreshing, refreshFleet, lobbyWatches, followLobby, cancelWatch } from '$ui/stores/fleet.js';
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js';
import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js';
import type { ZombieGame } from '$lib/map-aliases.js';
import { elapsedSince } from '$ui/duration.js';
import { serverTitle } from '$ui/server-labels.js';
interface HeldRow { target: string; betaId: number; } let query = $state('');
interface ServerRow { let filter = $state<FleetFilter>('all');
name: string; let game = $state('');
komodo_type: string; let sort = $state<FleetSort>('name');
komodo_name: string; let view = $state<'cards' | 'table'>('cards');
game_port: number | null; let grouping = $state<'map' | 'game' | 'none'>('none');
rcon_host: string | null; let operationsOpen = $state(false);
last_players: number | null; let pinned = $state<string[]>([]);
last_map: string | null; let preferencesReady = $state(false);
last_gametype: string | null;
last_seen_at: string | null;
last_error: string | null;
roster: RosterPlayer[] | null;
health: Health;
endpoint: { host: string; port: number } | null;
held: HeldRow[];
active_since: string | null;
inactive_since: string | null;
game_started_at: string | null;
game_ended_at: string | null;
}
let servers = $state<ServerRow[]>([]);
let deployOpen = $state(false); let deployOpen = $state(false);
let initialGame = $state<ZombieGame>('t5');
async function refresh(): Promise<void> { let initialMapSlug = $state('');
const res = await fetch('/api/servers'); const filters: { id: FleetFilter; label: string }[] = [
const body = (await res.json()) as { servers: ServerRow[] }; { id: 'all', label: 'All' }, { id: 'occupied', label: 'Occupied' }, { id: 'empty', label: 'Empty' },
servers = body.servers; { id: 'attention', label: 'Needs attention' }, { id: 'testing', label: 'Testing' }, { id: 'pinned', label: 'Pinned' }
} ];
onMount(() => { onMount(() => {
refresh(); try {
const saved = JSON.parse(localStorage.getItem('electric-cherry:fleet') ?? '{}');
if (saved.view === 'cards' || saved.view === 'table') view = saved.view;
if (['map', 'game', 'none'].includes(saved.grouping)) grouping = saved.grouping;
if (['name', 'players', 'round', 'duration'].includes(saved.sort)) sort = saved.sort;
if (filters.some(item => item.id === saved.filter)) filter = saved.filter;
if (['', 't5', 't6'].includes(saved.game)) game = saved.game;
if (Array.isArray(saved.pinned)) pinned = saved.pinned.filter((name: unknown) => typeof name === 'string');
} catch { /* Preferences also work when storage is disabled. */ }
preferencesReady = true;
void refreshFleet();
}); });
function liveFor(name: string, fallback: ServerRow) { $effect(() => {
const live = $statusByServer.get(name); if (!preferencesReady) return;
if (!live) { try { localStorage.setItem('electric-cherry:fleet', JSON.stringify({ view, grouping, sort, filter, game, pinned })); }
return { catch { /* The current view still works without persistent storage. */ }
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,
activeSince: fallback.active_since,
inactiveSince: fallback.inactive_since,
gameStartedAt: fallback.game_started_at,
gameEndedAt: fallback.game_ended_at
};
}
return {
players: live.players,
map: live.map,
gametype: live.gametype,
roster: live.roster,
health: live.health,
lastSeenAt: live.lastSeenAt,
lastError: live.lastError,
activeSince: live.activeSince,
inactiveSince: live.inactiveSince,
gameStartedAt: live.gameStartedAt,
gameEndedAt: live.gameEndedAt
};
}
// Aggregate fleet metrics — pulled from the same SSE store cards use, so let servers = $derived($fleetServers.map(server => server.health === 'online' && !freshServer(server, $uiNow)
// the hero and grid stay perfectly consistent. ? { ...server, health: 'stale' as const } : server));
let liveViews = $derived(servers.map((s) => liveFor(s.name, s))); let activeOperations = $derived($fleet.operations.filter(op => op.state !== 'done'));
let totalPlayers = $derived( let attentionOperations = $derived(activeOperations.filter(op => op.state === 'attention'));
liveViews.reduce((sum, v) => sum + (v.players ?? 0), 0) let campaigns = $derived(new Set(activeOperations.filter(op => op.kind === 'campaign').map(op => op.group)).size);
); let healthy = $derived(servers.filter(server => freshServer(server, $uiNow)));
let activeCount = $derived( let players = $derived(healthy.reduce((sum, server) => sum + (server.last_players ?? 0), 0));
liveViews.filter((v) => v.health === 'online').length let empty = $derived(healthy.filter(server => server.last_players === 0).length);
); let problems = $derived(servers.filter(server => server.health === 'offline' || server.health === 'stale'));
let totalCount = $derived(servers.length); let starting = $derived(servers.filter(server => server.health === 'starting').length);
let inFlightCampaigns = $derived( let filtered = $derived(servers.filter(server => matchesFleet(server, query, filter, game, pinned, activeOperations, $uiNow))
Array.from($deployments.values()).filter((d) => { .sort((a, b) => {
for (const s of d.servers.values()) if (s.state === 'pending') return true; const pinOrder = Number(pinned.includes(b.name)) - Number(pinned.includes(a.name));
return false; if (pinOrder) return pinOrder;
}).length const rank = (server: FleetServer) => sort === 'players' ? (freshServer(server, $uiNow) ? server.last_players ?? -1 : -1)
); : sort === 'round' ? currentRound(server, $telemetryByServer.get(server.name), $uiNow) ?? -1
let activeRatio = $derived(totalCount > 0 ? activeCount / totalCount : 0); : sort === 'duration' ? matchDuration(server) : 0;
return rank(b) - rank(a) || serverTitle(a.name).localeCompare(serverTitle(b.name), undefined, { numeric: true });
}));
let groups = $derived.by(() => {
const result = new Map<string, { title: string; game: string; servers: FleetServer[]; launch: ReturnType<typeof fleetIdentity>['launch'] }>();
for (const server of filtered) {
const identity = fleetIdentity(server);
const key = grouping === 'map' ? identity.group : grouping === 'game' ? identity.game : 'all';
if (!result.has(key)) result.set(key, { title: grouping === 'map' ? identity.map : grouping === 'game'
? identity.game.toUpperCase() || 'Other servers' : 'All servers', game: identity.game,
servers: [], launch: grouping === 'map' ? identity.launch : null });
result.get(key)!.servers.push(server);
}
return [...result.entries()].sort(([, a], [, b]) => a.title.localeCompare(b.title));
});
let spotlight = $derived(servers.filter(server => currentRound(server, $telemetryByServer.get(server.name), $uiNow) !== null)
.sort((a, b) => matchDuration(b) - matchDuration(a))[0]);
function matchDuration(server: FleetServer): number {
return freshServer(server, $uiNow) && server.active_since && server.game_started_at && !server.game_ended_at
&& (server.last_players ?? 0) > 0 ? Math.max(0, $uiNow - Date.parse(server.game_started_at)) : -1;
}
function togglePin(name: string) { pinned = pinned.includes(name) ? pinned.filter(item => item !== name) : [...pinned, name]; }
function launch(selection: { game: ZombieGame; slug: string } | null = null) {
initialGame = selection?.game ?? 't5'; initialMapSlug = selection?.slug ?? ''; deployOpen = true;
}
function watch(server: FleetServer, event: Event) {
const input = event.currentTarget as HTMLSelectElement;
const value = input.value;
input.value = '';
if (value === 'empty' || value === 'online') followLobby(server.name, value);
else {
const operation = activeOperations.find(op => op.key === value);
if (operation) followLobby(server.name, 'operation', operation);
}
}
function resetFilters() { query = ''; filter = 'all'; game = ''; }
</script> </script>
<section class="page"> <svelte:head><title>Fleet · Electric Cherry</title></svelte:head>
<!-- Fleet Overview — the first thing the eye lands on: three big numbers
that tell the operator the state of the world in under half a second. -->
<div class="hero">
<Card padding="lg" elevation="flat">
<div class="hero-grid">
<div class="hero-cell">
<Stat
label="Players online"
value={totalPlayers}
size="xl"
tone={totalPlayers > 0 ? 'online' : 'primary'}
tween
/>
</div>
<div class="hero-cell">
<Stat
label="Active servers"
value={`${activeCount}/${totalCount}`}
size="xl"
tone="primary"
tween={false}
/>
<div class="ratio-bar" aria-hidden="true">
<div class="ratio-fill" style="width: {activeRatio * 100}%;"></div>
</div>
</div>
<div class="hero-cell">
<a class="deploy-link" href="/campaigns">
<Stat
label="Campaigns in flight"
value={inFlightCampaigns}
size="xl"
tone={inFlightCampaigns > 0 ? 'accent' : 'primary'}
tween
/>
<span class="link-hint">View →</span>
</a>
</div>
</div>
</Card>
</div>
<SectionHeader <section class="page">
title="Fleet" <SectionHeader title="Fleet" subtitle="Your lobbies. Every player. The next move.">
subtitle={totalCount === 1 ? '1 server' : `${totalCount} servers`}
>
{#snippet actions()} {#snippet actions()}
<Button variant="primary" size="md" onclick={() => (deployOpen = true)}> <Button variant="ghost" size="sm" loading={$fleetRefreshing} onclick={() => void refreshFleet()}>Refresh</Button>
Deploy new server <Button variant="primary" onclick={() => launch()}>Deploy new server</Button>
</Button>
{/snippet} {/snippet}
</SectionHeader> </SectionHeader>
<DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} />
<DeployServerDialog bind:open={deployOpen} /> {#if $fleetError}
<div class="error" role="alert"><strong>{$fleet.receivedAt ? 'Showing the last available snapshot.' : 'Fleet is unavailable.'}</strong> {$fleetError}
<button onclick={() => void refreshFleet()}>Retry now</button></div>
{/if}
{#if servers.length === 0} {#if !$fleet.receivedAt}
<EmptyState {#if !$fleetError}<div class="loading" role="status">Loading your fleet…</div>{/if}
title="No servers registered yet"
description="Point SERVERS_ROOT at your fleet directory and refresh to hydrate the dashboard."
/>
{:else} {:else}
<div class="cards"> <div class="overview">
{#each servers as s, i (s.name)} <div><Stat label="Players online" value={players} size="lg" tone={players > 0 ? 'online' : 'primary'} tween /><span class="hint">From fresh server reports</span></div>
{@const live = liveFor(s.name, s)} <div><Stat label="Healthy servers" value={`${healthy.length}/${servers.length}`} size="lg" tween={false} /><span class="hint">{starting ? `${starting} starting` : 'Live fleet status'}</span></div>
<div in:fly={{ y: 12, duration: 320, delay: Math.min(i * 40, 320), easing: cubicOut }}> <button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}><Stat label="Empty lobbies" value={empty} size="lg" tween /><span class="hint">Find an open lobby →</span></button>
<ServerCard <a class="metric-link" href="/campaigns"><Stat label="Active campaigns" value={campaigns} size="lg" tween /><span class="hint">View progress →</span></a>
name={s.name}
players={live.players}
map={live.map}
gametype={live.gametype}
roster={live.roster}
health={live.health}
lastSeenAt={live.lastSeenAt}
lastError={live.lastError}
held={s.held}
activeSince={live.activeSince}
inactiveSince={live.inactiveSince}
gameStartedAt={live.gameStartedAt}
gameEndedAt={live.gameEndedAt}
/>
</div>
{/each}
</div> </div>
{#if servers.length || activeOperations.length}
<div class="attention" class:has-issues={problems.length > 0 || attentionOperations.length > 0}>
<span class="attention-mark" aria-hidden="true">{problems.length || attentionOperations.length ? '!' : '✓'}</span>
<div class="attention-copy">
{#if problems.length}
<button onclick={() => { resetFilters(); filter = 'attention'; }}>{problems.length} {problems.length === 1 ? 'server needs' : 'servers need'} attention →</button>
{:else}<strong>{servers.length ? starting ? `${starting} ${starting === 1 ? 'server is' : 'servers are'} starting` : 'All servers healthy' : 'Preparing your fleet'}</strong>{/if}
<span>{problems.length ? 'Open a server to inspect its connection and diagnostics.' : 'Live health, match reports, and operation progress in one place.'}</span>
</div>
{#if attentionOperations.length}<a href="#fleet-operations" onclick={() => operationsOpen = true}>{attentionOperations.length} operation {attentionOperations.length === 1 ? 'needs' : 'need'} attention →</a>{/if}
</div>
{/if}
{#if spotlight}
<a class="spotlight" href="/servers/{encodeURIComponent(spotlight.name)}">
<div><span class="eyebrow">Longest running match</span><strong>{serverTitle(spotlight.name)}</strong></div>
<span class="round">Round {currentRound(spotlight, $telemetryByServer.get(spotlight.name), $uiNow)}</span>
<span>{spotlight.last_players} {spotlight.last_players === 1 ? 'survivor' : 'survivors'} · {elapsedSince(spotlight.game_started_at, $uiNow)} <span aria-hidden="true">→</span></span>
</a>
{/if}
{#if $lobbyWatches.length}
<div class="watches" aria-label="Lobby watches">
<div><strong>Following {$lobbyWatches.length} {$lobbyWatches.length === 1 ? 'condition' : 'conditions'}</strong><span class="hint">Notifications stay active as you navigate. Keep this app open.</span></div>
{#each $lobbyWatches as item (item.id)}
<button title="Cancel this watch" onclick={() => cancelWatch(item.id)} aria-label="Cancel {item.kind} watch for {serverTitle(item.server)}">{serverTitle(item.server)} · {item.kind === 'empty' ? 'when empty' : item.kind === 'online' ? 'when online' : 'when complete'} ×</button>
{/each}
</div>
{/if}
{#if activeOperations.length}
<details id="fleet-operations" class="operations" bind:open={operationsOpen}>
<summary>{activeOperations.length} server {activeOperations.length === 1 ? 'operation' : 'operations'} in progress{attentionOperations.length ? ` · ${attentionOperations.length} need attention` : ''}</summary>
<div class="operation-list">{#each activeOperations as operation (operation.key)}
<a href={operation.href} class:issue={operation.state === 'attention'}><strong>{serverTitle(operation.server)}</strong><span>{operation.label}</span><small>{operation.detail}</small></a>
{/each}</div>
</details>
{/if}
{#if servers.length === 0}
<EmptyState title="Your first lobby starts here" description="Deploy a server or choose a saved blueprint to bring your fleet online." />
{:else}
<div class="toolbar">
<div class="search-row">
<label class="search"><span>Find a lobby or player</span><input type="search" bind:value={query} placeholder="Search players, maps, or server names…" /></label>
<label>Game<select bind:value={game}><option value="">All games</option><option value="t5">T5 · Black Ops</option><option value="t6">T6 · Black Ops II</option></select></label>
<label>Group by<select bind:value={grouping}><option value="map">Map</option><option value="game">Game</option><option value="none">None</option></select></label>
<label>Sort by<select bind:value={sort}><option value="name">Lobby name</option><option value="players">Most players</option><option value="round">Highest round</option><option value="duration">Longest match</option></select></label>
</div>
<div class="filter-row">
<div class="chips" aria-label="Filter servers">{#each filters as item}
<button class:selected={filter === item.id} aria-pressed={filter === item.id} onclick={() => filter = item.id}>{item.label}<span>{servers.filter(server => matchesFleet(server, '', item.id, game, pinned, activeOperations, $uiNow)).length}</span></button>
{/each}</div>
<div class="view-toggle" aria-label="Display mode"><button class:selected={view === 'cards'} aria-pressed={view === 'cards'} onclick={() => view = 'cards'}>Cards</button><button class:selected={view === 'table'} aria-pressed={view === 'table'} onclick={() => view = 'table'}>Table</button></div>
</div>
<div class="result-count"><span role="status">Showing {filtered.length} of {servers.length} servers</span>{#if query || filter !== 'all' || game}<button onclick={resetFilters}>Clear filters</button>{/if}<span class="hint">Pinned lobbies appear first in each group.</span></div>
</div>
{#if filtered.length === 0}<EmptyState title="No matching lobbies" description="Try a different player or map, or clear your filters." />{/if}
{#each groups as [key, group] (key)}
<section class="map-group" aria-label={group.title}>
<div class="group-heading"><div><span class="eyebrow">{grouping === 'map' ? group.game.toUpperCase() : 'Fleet lineup'}</span><h2>{group.title}</h2><p>{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</p></div>
{#if group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
</div>
{#if view === 'cards'}
<div class="cards">{#each group.servers as server (server.name)}
<article class="server-tile" class:pinned={pinned.includes(server.name)}>
<ServerCard name={server.name} players={server.last_players} map={server.last_map ?? server.map}
gametype={server.last_gametype} roster={server.roster} health={server.health}
lastSeenAt={server.last_seen_at} lastError={server.last_error} held={server.held}
activeSince={server.active_since} inactiveSince={server.inactive_since}
gameStartedAt={server.game_started_at} gameEndedAt={server.game_ended_at} compactEmpty
mapLabelOverride={fleetIdentity(server).map} />
{@render serverOperations(server)}
<div class="tile-actions">{@render serverActions(server)}</div>
</article>
{/each}</div>
{:else}
<div class="table-scroll"><table><caption class="sr-only">{group.title} servers</caption><thead><tr><th>Lobby</th><th>Status</th><th>Players</th><th>Round / match</th><th>Operation</th><th>Follow</th></tr></thead><tbody>
{#each group.servers as server (server.name)}
{@const round = currentRound(server, $telemetryByServer.get(server.name), $uiNow)}
<tr><td><a class="lobby-link" href="/servers/{encodeURIComponent(server.name)}">{serverTitle(server.name)}</a><small>{fleetIdentity(server).map}</small></td>
<td><span class="health"><StatusPip health={server.health} />{server.health}</span></td><td>{freshServer(server, $uiNow) ? server.last_players ?? '—' : '—'}</td>
<td>{round !== null ? `Round ${round}` : '—'}{#if matchDuration(server) >= 0}<small>{elapsedSince(server.game_started_at, $uiNow)}</small>{/if}</td>
<td>{@render serverOperations(server, true)}</td><td><div class="table-actions">{@render serverActions(server)}</div></td></tr>
{/each}
</tbody></table></div>
{/if}
</section>
{/each}
{/if}
{/if} {/if}
</section> </section>
{#snippet serverOperations(server: FleetServer, showEmpty = false)}
{@const operations = activeOperations.filter(op => op.server === server.name)}
{#if operations.length}<div class="server-operations">{#each operations as operation (operation.key)}
<a href={operation.href} class:issue={operation.state === 'attention'} title={operation.detail}><span>{operation.label} →</span><small>{operation.detail}</small></a>
{/each}</div>{:else if showEmpty}<span class="hint">No pending work</span>{/if}
{/snippet}
{#snippet serverActions(server: FleetServer)}
<button class="pin" class:selected={pinned.includes(server.name)} aria-pressed={pinned.includes(server.name)} aria-label="Pin {serverTitle(server.name)}" onclick={() => togglePin(server.name)}>{pinned.includes(server.name) ? '★ Pinned' : '☆ Pin'}</button>
<select class="watch-select" aria-label="Follow {serverTitle(server.name)}" onchange={event => watch(server, event)}>
<option value="">Follow lobby…</option>
<option value="empty" disabled={freshServer(server, $uiNow) && server.last_players === 0}>Tell me when it’s empty</option>
<option value="online" disabled={freshServer(server, $uiNow)}>Tell me when it’s online</option>
{#each activeOperations.filter(op => op.server === server.name && (op.kind === 'campaign' || op.kind === 'deployment') && op.state === 'active') as operation (operation.key)}
<option value={operation.key}>When complete: {operation.label}</option>
{/each}
</select>
{/snippet}
<style> <style>
.page { .page { display: flex; flex-direction: column; gap: 24px; }
display: flex; button, input, select { font: inherit; }
flex-direction: column; button { cursor: pointer; }
gap: var(--space-10); button:focus-visible, a:focus-visible, select:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
} .overview { display: grid; grid-template-columns: repeat(4, 1fr); padding: 24px; gap: 24px; background: var(--surface); border-radius: 16px; border: 1px solid var(--border-subtle); }
.overview > * { min-width: 0; }
.hero-grid { .metric-link { text-align: left; color: inherit; text-decoration: none; background: none; border: 0; padding: 0; }
display: grid; .metric-link:hover .hint { color: var(--accent-primary-hover); }
grid-template-columns: repeat(3, 1fr); .hint { display: block; color: var(--text-secondary); font-size: 12px; margin-top: 6px; }
gap: var(--space-8); .attention { display: flex; gap: 14px; align-items: center; padding: 16px 20px; border: 1px solid var(--border-subtle); border-radius: 12px; }
} .attention-mark { display: grid; place-items: center; width: 28px; height: 28px; flex-shrink: 0; border-radius: 50%; background: var(--surface-raised); color: var(--state-online); }
.hero-cell { .has-issues { border-color: color-mix(in srgb, var(--state-warning) 35%, transparent); }
display: flex; .has-issues .attention-mark { color: var(--state-warning); }
flex-direction: column; .attention-copy { flex: 1; display: flex; flex-direction: column; gap: 5px; }
gap: var(--space-3); .attention-copy span { font-size: 13px; color: var(--text-secondary); }
min-width: 0; .attention button, .result-count button, .error button { background: none; color: var(--accent-primary-hover); border: 0; padding: 0; text-align: left; }
} .attention a { font-size: 13px; color: var(--state-warning); }
.spotlight { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; padding: 20px 24px; background: linear-gradient(110deg, color-mix(in srgb, var(--accent-primary) 15%, var(--surface)), var(--surface)); border: 1px solid var(--border-strong); border-radius: 14px; color: var(--text-primary); text-decoration: none; }
.ratio-bar { .spotlight > div { flex: 1; min-width: 190px; }
height: 3px; .spotlight strong { display: block; margin-top: 6px; }
width: 100%; .spotlight > span:last-child { color: var(--text-secondary); font-size: 13px; }
max-width: 220px; .round { font-family: var(--font-mono); font-size: 23px; color: var(--state-online); }
background: var(--surface-overlay); .eyebrow { color: var(--text-secondary); text-transform: uppercase; font-size: 10px; letter-spacing: .12em; font-weight: 600; }
border-radius: var(--radius-pill); .toolbar { display: flex; flex-direction: column; gap: 18px; }
overflow: hidden; .search-row { display: flex; gap: 12px; flex-wrap: wrap; }
} label { display: flex; flex-direction: column; gap: 8px; color: var(--text-secondary); font-size: 12px; }
.ratio-fill { .search { flex: 1; min-width: min(280px, 100%); }
height: 100%; input, select { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 8px; min-height: 42px; padding: 10px 12px; max-width: 100%; }
background: var(--accent-primary); input::placeholder { color: var(--text-secondary); }
transition: width var(--dur-slow) var(--ease-standard); .filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
} .chips, .view-toggle { display: flex; gap: 6px; flex-wrap: wrap; }
.chips button, .view-toggle button, .pin, .watches button { background: var(--surface); border: 1px solid var(--border-strong); border-radius: 8px; padding: 8px 12px; color: var(--text-secondary); font-size: 12px; }
.deploy-link { .chips button span { margin-left: 8px; opacity: .75; font-variant-numeric: tabular-nums; }
display: flex; button.selected { background: color-mix(in srgb, var(--accent-primary) 15%, var(--surface)); color: var(--accent-primary-hover); border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
align-items: baseline; .result-count { justify-content: flex-start; font-size: 12px; color: var(--text-secondary); }
justify-content: space-between; .result-count .hint { margin: 0 0 0 auto; }
gap: var(--space-3); .map-group { display: flex; flex-direction: column; gap: 16px; }
text-decoration: none; .group-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
color: inherit; h2 { margin: 4px 0 6px; font-size: 20px; letter-spacing: -.03em; }
border-radius: var(--radius-md); .group-heading p { margin: 0; color: var(--text-secondary); font-size: 12px; }
transition: opacity var(--dur-fast) var(--ease-standard); .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(310px, 100%), 1fr)); gap: 16px; align-items: start; }
} .server-tile { min-width: 0; border: 1px solid var(--border-subtle); border-radius: 14px; overflow: hidden; background: var(--surface); }
.deploy-link:hover { opacity: 0.85; } .server-tile.pinned { border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
.deploy-link:focus-visible { outline: none; box-shadow: var(--focus-ring); } .server-tile :global(.card) { border-radius: 0; }
.link-hint { .tile-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border-subtle); }
font-size: var(--text-xs); .pin { white-space: nowrap; }
color: var(--text-tertiary); .watch-select { font-size: 12px; min-height: 36px; min-width: 0; width: 145px; padding: 6px; }
letter-spacing: var(--tracking-wide); .server-operations { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; font-size: 12px; }
} .server-operations a { text-decoration: none; color: var(--accent-primary-hover); overflow-wrap: anywhere; }
.server-operations small { display: block; margin-top: 5px; color: var(--text-secondary); }
.cards { .server-operations .issue, .operation-list .issue { color: var(--state-warning); }
display: grid; .operations { border: 1px solid var(--border-subtle); padding: 16px 20px; border-radius: 12px; font-size: 13px; }
gap: var(--space-4); summary { cursor: pointer; }
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); .operation-list { display: grid; gap: 10px; margin-top: 16px; }
} .operation-list a { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; padding: 12px; text-decoration: none; background: var(--surface); border-radius: 8px; color: var(--text-primary); }
.cards > div { display: flex; flex-direction: column; } .operation-list small { grid-column: 1 / -1; color: var(--text-secondary); overflow-wrap: anywhere; }
.cards > div > :global(*) { flex: 1; } .watches { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: 13px; }
.watches > div { margin-right: auto; }
@media (max-width: 900px) { .error { padding: 16px; border: 1px solid var(--state-warning); border-radius: 12px; font-size: 13px; line-height: 1.7; }
.hero-grid { .error button { margin-left: 8px; }
grid-template-columns: 1fr; .loading { padding: 70px 20px; text-align: center; color: var(--text-secondary); background: var(--surface); border-radius: 16px; }
gap: var(--space-5); .table-scroll { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: 12px; }
} table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: 13px; text-align: left; }
} th { color: var(--text-secondary); font-weight: 500; white-space: nowrap; font-size: 12px; }
@media (max-width: 640px) { th, td { padding: 14px 16px; border-bottom: 1px solid var(--border-subtle); }
.cards { grid-template-columns: 1fr; } tr:last-child td { border-bottom: 0; }
} td small { display: block; color: var(--text-secondary); margin-top: 6px; }
td .server-operations { padding: 0; min-width: 180px; }
.lobby-link { color: var(--text-primary); text-decoration: none; font-weight: 600; white-space: nowrap; }
.health { display: flex; align-items: center; gap: 8px; text-transform: capitalize; }
.table-actions { display: flex; align-items: center; gap: 8px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
@media (max-width: 900px) { .overview { grid-template-columns: repeat(2, 1fr); } .attention { flex-wrap: wrap; } }
@media (max-width: 600px) { .page { gap: 20px; } .overview { padding: 18px; gap: 22px 16px; } .search-row > label:not(.search) { flex: 1; min-width: 100px; } .search { flex-basis: 100%; } .group-heading { align-items: flex-start; } .result-count .hint { width: 100%; margin: 0; } .spotlight { padding: 18px; gap: 12px; } .spotlight > div { flex-basis: 100%; } .operation-list a { grid-template-columns: 1fr; } }
</style> </style>
+6
View File
@@ -0,0 +1,6 @@
import { json } from '@sveltejs/kit';
import { fleetOperations } from '$lib/server/fleet/overview.js';
export function GET() {
return json({ operations: fleetOperations() });
}
+124
View File
@@ -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' }));
});
});