Refresh Fleet with discovery, operation progress, and lobby watches
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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,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);
|
||||||
};
|
};
|
||||||
|
|||||||
+292
-225
@@ -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>
|
</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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
||||||
</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>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { fleetOperations } from '$lib/server/fleet/overview.js';
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return json({ operations: fleetOperations() });
|
||||||
|
}
|
||||||
@@ -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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user