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
@@ -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<ZombieGame>('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.');
+5 -2
View File
@@ -1,11 +1,14 @@
<script lang="ts">
import { telemetryByServer, uiNow } from '$ui/stores/servers.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 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>
{#if report}
{#if report && fresh}
<div class="round" aria-live="polite">
<strong>Round {report.round}</strong>
<span title={report.receivedAt}>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago · {report.players} players</span>
+10 -6
View File
@@ -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}
<div class="players" aria-live="polite">
<Stat
value={players ?? 0}
value={players ?? '—'}
size="md"
tone={players !== null && players > 0 ? 'online' : 'primary'}
tween
@@ -123,9 +127,9 @@
{/if}
</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">
{#if activeElapsed}
<div class="metric">
@@ -159,7 +163,7 @@
{/each}
</ul>
{: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}
+2 -1
View File
@@ -4,7 +4,7 @@
import { toasts, dismiss } from '$ui/toast.js';
</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)}
<div
class="toast v-{t.variant}"
@@ -13,6 +13,7 @@
>
<span class="dot" aria-hidden="true"></span>
<span class="msg">{t.message}</span>
{#if t.href}<a href={t.href} onclick={() => dismiss(t.id)}>View</a>{/if}
<button
type="button"
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 statusReceivedAt = writable(0);
// 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
@@ -67,6 +68,7 @@ export function startServerStream(): void {
for (const e of data.entries) m.set(e.server, e);
return m;
});
statusReceivedAt.set(Date.now());
});
if (tickInterval === null) {
tickInterval = setInterval(() => _uiNow.set(Date.now()), 1000);
+4 -2
View File
@@ -14,6 +14,7 @@ export interface Toast {
message: string;
variant: ToastVariant;
ttl: number;
href?: string;
}
export const toasts = writable<Toast[]>([]);
@@ -22,13 +23,14 @@ let nextId = 1;
export function toast(
message: string,
opts: { variant?: ToastVariant; ttl?: number } = {}
opts: { variant?: ToastVariant; ttl?: number; href?: string } = {}
): void {
const t: Toast = {
id: nextId++,
message,
variant: opts.variant ?? 'info',
ttl: opts.ttl ?? 3500
ttl: opts.ttl ?? 3500,
href: opts.href
};
toasts.update((arr) => [...arr, t]);
if (t.ttl > 0) {