diff --git a/src/lib/fleet-overview.ts b/src/lib/fleet-overview.ts index ff5a329..c8a969d 100644 --- a/src/lib/fleet-overview.ts +++ b/src/lib/fleet-overview.ts @@ -8,6 +8,8 @@ import { mapTitle, serverTitle } from './ui/server-labels.js'; export interface FleetServer { plutonium?: import('./plutonium.js').PlutoniumVersion; + host_id?: string | null; + host?: Pick | null; container_state?: string | null; container_checked_at?: string | null; last_checked_at?: string | null; diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index db3f251..e710c7c 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -1,23 +1,17 @@ - - {#if restarting} - - {/if} - +
-
- -
-
+ +

{title}

+ + {#if identity.lobby !== null}Lobby {identity.lobby}{/if} + {#if game}{game}{/if} + {#if compact}{emptyElapsed ? `Empty for ${emptyElapsed}` : 'Empty lobby'}{/if} + +
+
- {#if health === 'online'} -
- 0 ? 'online' : 'primary'} - tween - /> -
- {/if} + {#if onPin}{/if}
- {#if gtLabel || showMap}
{#if showMap}{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}{/if}{#if gtLabel}{gtLabel}{/if}
{/if} + {#if !compact} + {#if longestMatch && health === 'online'}
Longest match
{/if} + {#if showMap}

{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}

{/if} + {#if health === 'online'} +
+
Players
{players ?? '—'}/{knownCapacity ?? '?'}
+
Round
{currentRound ?? '—'}
+
Match time
{gameElapsed ?? '—'}
+
+ {#if gameEndedAt && players !== 0}

Match ended · waiting for the next match

{/if} + {:else if health === 'stale'} +

Current player count unknown. Last reported: {players ?? 'unknown'}{players !== null ? ` ${players === 1 ? 'player' : 'players'}` : ''}{lastSeenAt ? ` · ${elapsedSince(lastSeenAt, $uiNow)} ago` : ''}.

+ {/if} - {#if health === 'stale'} -
Players unknownLast known: {players ?? 'unknown'}{players !== null ? ' players' : ''}{lastSeenAt ? ` · ${ago(lastSeenAt, $uiNow)}` : ''}
- {#if topRoster.length}
Last reported players
{/if} - {/if} + {#if health === 'online' || health === 'stale'} + {#if topRoster.length} +
+ + + {#each topRoster as player (player.guid ?? player.num)} + + + + {/each} +
PlayerScorePing (ms)
{player.name || '—'}{player.score === null ? '—' : number.format(player.score)}{player.ping === 999 ? 'Connecting' : player.ping === null ? '—' : number.format(player.ping)}
+
+ {:else if players !== 0}

{health === 'stale' ? 'Waiting for a fresh report' : 'Player roster unavailable'}

{/if} + {/if} - {#if health === 'online' && players !== null && players > 0 && gameInProgress}{/if} - - {#if showTiming && !(compactEmpty && players === 0)} -
- {#if resetting} -
- Resetting - {resetElapsed} -
- {:else if activeElapsed} -
- Active - {activeElapsed} -
- {:else if inactiveElapsed} -
- Inactive - {inactiveElapsed} -
- {/if} - {#if gameElapsed} -
- Match - {gameElapsed} -
- {/if} -
- {/if} - - {#if showRoster} - {#if topRoster.length > 0} -
    - {#each topRoster as p (p.guid ?? p.name)} -
  • - {p.name || '—'} - {p.score ?? '—'} - {p.ping === 999 ? 'Connecting' : p.ping === null ? '—' : `${p.ping}ms`} -
  • - {/each} -
- {:else} -
{health === 'stale' ? 'Waiting for a fresh report' : players === 0 ? 'Lobby is empty' : 'Player roster unavailable'}
+ {#if health === 'offline'} +

{error?.detail || 'Server status is unavailable. Open diagnostics for details.'}

+ {#if lastSeenAt}

Last report {elapsedSince(lastSeenAt, $uiNow)} ago

{/if} {/if} {/if} - {#if held.length > 0} -
- {#each held as h (h.target)} - β {h.target} - {/each} -
- {/if} - -
- {#if health === 'offline' && lastError} - {error?.detail || 'Server status is unavailable. Open diagnostics for details.'} - {:else if lastSeenAt} - Last seen {ago(lastSeenAt, $uiNow)} - {:else if lastRestartedAt} - Restart {ago(lastRestartedAt, $uiNow)} - {:else} - No data - {/if} -
+ {#if held.length > 0}
{#each held as h (h.target)}β {h.target}{/each}
{/if} diff --git a/src/lib/ui/server-health.ts b/src/lib/ui/server-health.ts new file mode 100644 index 0000000..c068582 --- /dev/null +++ b/src/lib/ui/server-health.ts @@ -0,0 +1,101 @@ +import type { FleetServer } from '../fleet-overview.js'; +import { freshStatusTimestamp } from '../server-freshness.js'; +import { decodeRconError } from './rconError.js'; +import { statusPresentation } from './status.js'; + +export type ServerHealthState = 'online' | 'host-offline' | 'not-responding' | 'stopped' | 'starting' | 'provisioning' | 'unknown'; +export interface ServerHealthPresentation { + state: ServerHealthState; + label: string; + reason: string; + actionLabel: string; + actionHref: string; + /** Last relevant evidence, not an inferred time when the state began. */ + since: string | null; +} + +function observedAt(value: string | null | undefined, now: number): string | null { + const time = Date.parse(value ?? ''); + return Number.isFinite(time) && time <= now + 1_000 ? value! : null; +} + +/** Explain the available evidence without treating a missing report as a stopped game. */ +export function serverHealth(server: FleetServer, now: number): ServerHealthPresentation { + const evidence = healthEvidence(server, now); + return { ...evidence, label: statusPresentation(evidence.state).label }; +} + +function healthEvidence(server: FleetServer, now: number): Omit { + const href = `/servers/${encodeURIComponent(server.name)}`; + const diagnostics = `${href}?tab=diagnostics`; + const host = server.host; + const hostId = host?.id || server.host_id; + const hostOffline = !!host && (host.connectionState === 'offline' || !host.connected + || !freshStatusTimestamp(host.lastHeartbeat, now)); + if (hostOffline) return { + state: 'host-offline', reason: `${host.name ? `${host.name}'s host agent` : 'The host agent'} is unavailable. The game may still be running.`, + actionLabel: hostId ? 'Check host' : 'Open diagnostics', actionHref: hostId ? `/hosts/${encodeURIComponent(hostId)}` : diagnostics, + since: observedAt(host.lastHeartbeat, now) + }; + + if (server.health === 'provisioning') return { + state: 'provisioning', reason: 'Server setup is in progress. It will be ready after its health checks pass.', + actionLabel: 'View progress', actionHref: `${href}?tab=history`, since: observedAt(server.telemetry_after, now) + }; + if (server.health === 'starting') return { + state: 'starting', reason: 'The game is starting. Waiting for a successful status report.', + actionLabel: 'View progress', actionHref: `${href}?tab=history`, + since: observedAt(server.telemetry_after, now) ?? observedAt(server.container_checked_at, now) + }; + + const containerAt = observedAt(server.container_checked_at, now); + const gameAt = observedAt(server.last_seen_at, now); + // A last game check cannot renew a container observation. Reports predating + // the most recent lifecycle submission describe the previous process. + const containerCurrent = freshStatusTimestamp(containerAt, now) + && (!server.telemetry_after || Date.parse(containerAt!) > Date.parse(server.telemetry_after)); + const gameCurrent = freshStatusTimestamp(gameAt, now) + && (!server.telemetry_after || Date.parse(gameAt!) > Date.parse(server.telemetry_after)); + const hostMissing = !!server.host_id && !host; + if (!hostMissing && containerCurrent && ['stopped', 'exited', 'created', 'dead'].includes(server.container_state ?? '') + && (!gameAt || Date.parse(gameAt) < Date.parse(containerAt!))) return { + state: 'stopped', reason: 'The latest container check confirmed that the game is stopped.', + actionLabel: 'Start server', actionHref: `${href}?action=start`, since: containerAt + }; + + const containerAllowsOnline = !server.container_state || server.container_state === 'unknown' + || server.container_state === 'running' && (!server.container_checked_at || containerCurrent); + if (!hostMissing && server.health === 'online' && !server.last_error && gameCurrent && containerAllowsOnline) return { + state: 'online', reason: 'The game is responding.', actionLabel: 'View server', actionHref: href, since: gameAt + }; + if (gameCurrent && !server.last_error) return { + state: 'unknown', reason: 'The game recently responded, but its current container state has not been verified.', + actionLabel: hostMissing ? 'Check host' : 'Open diagnostics', + actionHref: hostMissing ? `/hosts/${encodeURIComponent(server.host_id!)}` : diagnostics, since: gameAt + }; + + const error = decodeRconError(server.last_error); + const errorReason = error ? { + 'no-config': 'The game connection is not configured.', + 'no-port': 'The game connection has no port configured.', + 'send-error': 'The latest game connection attempt could not be completed.', + timeout: 'The game did not answer the latest status check.', + 'bad-reply': 'The latest game response could not be read.', + 'no-players': 'The latest game report did not include a player count.' + }[error.reason] : null; + if (!hostMissing && server.container_state === 'running' && containerCurrent) return { + state: 'not-responding', reason: errorReason ?? 'The container is running, but the game has no current status report.', + actionLabel: 'Test connection', actionHref: diagnostics, since: gameAt ?? observedAt(server.last_checked_at, now) + }; + if (!hostMissing && (server.health === 'stale' || server.health === 'online') && gameAt + && (!server.telemetry_after || Date.parse(gameAt) > Date.parse(server.telemetry_after))) return { + state: 'not-responding', reason: errorReason ?? 'The last game report is no longer current. Player count is unknown.', + actionLabel: 'Test connection', actionHref: diagnostics, since: gameAt + }; + return { + state: 'unknown', reason: errorReason ?? 'There is no current evidence of the game or container state.', + actionLabel: hostMissing ? 'Check host' : 'Open diagnostics', + actionHref: hostMissing ? `/hosts/${encodeURIComponent(server.host_id!)}` : diagnostics, + since: gameAt ?? containerAt + }; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index aa31826..af9c9ea 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -10,15 +10,15 @@ import { onMount, untrack } from 'svelte'; import { afterNavigate, replaceState } from '$app/navigation'; import { page } from '$app/stores'; - import RuntimeVersion from '$ui/components/RuntimeVersion.svelte'; import ServerCard from '$ui/components/ServerCard.svelte'; import DeployServerDialog from '$ui/components/DeployServerDialog.svelte'; import Button from '$ui/components/primitives/Button.svelte'; import ServerIdentity from '$ui/components/ServerIdentity.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte'; - import StatusPip from '$ui/components/primitives/StatusPip.svelte'; - import { fleet, fleetServers, fleetError, fleetRefreshing, fleetIntegrations, refreshFleet } from '$ui/stores/fleet.js'; + import StatusBadge from '$ui/components/primitives/StatusBadge.svelte'; + import { serverHealth } from '$ui/server-health.js'; + import { fleet, fleetServers, fleetError, fleetIntegrations, refreshFleet } 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'; @@ -29,7 +29,7 @@ let query = $state(''); let filter = $state('all'); let game = $state(''); - let sort = $state('name'); + let sort = $state('duration'); let view = $state<'cards' | 'table'>('cards'); let grouping = $state<'map' | 'game' | 'none'>('none'); let viewOptionsOpen = $state(false); @@ -41,29 +41,24 @@ let initialMapSlug = $state(''); const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator'); const filters: { id: FleetFilter; label: string }[] = [ - { id: 'all', label: 'All' }, { id: 'occupied', label: 'Occupied' }, { id: 'empty', label: 'Empty' }, + { id: 'all', label: 'All' }, { id: 'occupied', label: 'Live' }, { id: 'empty', label: 'Empty' }, { id: 'attention', label: 'Needs attention' }, { id: 'testing', label: 'Testing' }, { id: 'pinned', label: 'Pinned' } ]; function readViewUrl() { const params = new URL(window.location.href).searchParams; query = params.get('q') ?? ''; - if (params.get('view') === 'cards' || params.get('view') === 'table') view = params.get('view') as typeof view; - if (['map', 'game', 'none'].includes(params.get('group') ?? '')) grouping = params.get('group') as typeof grouping; - if (['name', 'players', 'round', 'duration'].includes(params.get('sort') ?? '')) sort = params.get('sort') as FleetSort; - if (filters.some(item => item.id === params.get('filter'))) filter = params.get('filter') as FleetFilter; - if (params.has('game') && ['', 't5', 't6'].includes(params.get('game')!)) game = params.get('game')!; + view = params.get('view') === 'table' ? 'table' : 'cards'; + grouping = ['map', 'game'].includes(params.get('group') ?? '') ? params.get('group') as typeof grouping : 'none'; + sort = ['name', 'players', 'round', 'duration'].includes(params.get('sort') ?? '') ? params.get('sort') as FleetSort : 'duration'; + filter = filters.some(item => item.id === params.get('filter')) ? params.get('filter') as FleetFilter : 'all'; + game = ['t5', 't6'].includes(params.get('game') ?? '') ? params.get('game')! : ''; } afterNavigate(() => { routerReady = true; if (preferencesReady) readViewUrl(); }); onMount(() => { 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. */ } readViewUrl(); @@ -73,13 +68,16 @@ $effect(() => { if (!preferencesReady) return; - try { localStorage.setItem('electric-cherry:fleet', JSON.stringify({ view, grouping, sort, filter, game, pinned })); } + try { localStorage.setItem('electric-cherry:fleet', JSON.stringify({ pinned })); } catch { /* The current view still works without persistent storage. */ } const values = { q: query, view, group: grouping, sort, filter, game }; if (!routerReady) return; untrack(() => { const url = new URL(window.location.href); - for (const [key, value] of Object.entries(values)) url.searchParams.set(key, value); + const defaults: Record = { q: '', view: 'cards', group: 'none', sort: 'duration', filter: 'all', game: '' }; + for (const [key, value] of Object.entries(values)) { + if (value === defaults[key]) url.searchParams.delete(key); else url.searchParams.set(key, value); + } if (url.href !== window.location.href) replaceState(url, $page.state); }); }); @@ -91,16 +89,19 @@ let activeOperations = $derived(snapshot.operations.filter(op => op.state !== 'done')); let attentionOperations = $derived(activeOperations.filter(op => op.state === 'attention')); let campaigns = $derived(new Set(activeOperations.filter(op => op.kind === 'campaign').map(op => op.group)).size); - let healthy = $derived(servers.filter(server => freshServer(server, observationNow()))); + let healthy = $derived(servers.filter(server => freshServer(server, observationNow()) && serverHealth(server, observationNow()).state === 'online')); let players = $derived(healthy.reduce((sum, server) => sum + (server.last_players ?? 0), 0)); let empty = $derived(healthy.filter(server => server.last_players === 0).length); - let problems = $derived(servers.filter(server => server.health === 'offline' || server.health === 'stale')); - let starting = $derived(servers.filter(server => server.health === 'starting').length); - let provisioning = $derived(servers.filter(server => server.health === 'provisioning').length); + const needsAttention = (server: FleetServer) => !['online', 'starting', 'provisioning'].includes(serverHealth(server, observationNow()).state) || activeOperations.some(op => op.server === server.name && op.state === 'attention'); + function matchesView(server: FleetServer, search: string, selected: FleetFilter) { + return matchesFleet(server, search, selected === 'attention' ? 'all' : selected, game, pinned, activeOperations, observationNow()) + && (selected !== 'attention' || needsAttention(server)) + && (!['occupied', 'empty'].includes(selected) || serverHealth(server, observationNow()).state === 'online'); + } let filtered = $derived.by(() => { const now = observationNow(); const pinnedNames = new Set(pinned); - return servers.filter(server => matchesFleet(server, query, filter, game, pinned, activeOperations, now)) + return servers.filter(server => matchesView(server, query, filter)) .map(server => ({ server, title: serverTitle(server.name), @@ -109,20 +110,41 @@ : sort === 'round' ? currentRound(server, $telemetryByServer.get(server.name), now) ?? -1 : sort === 'duration' ? matchDuration(server, now) : 0 })) - .sort((a, b) => b.pinned - a.pinned || b.rank - a.rank || serverTitleCollator.compare(a.title, b.title)) + .sort((a, b) => Number(needsAttention(b.server)) - Number(needsAttention(a.server)) || b.pinned - a.pinned || b.rank - a.rank || serverTitleCollator.compare(a.title, b.title)) .map(({ server }) => server); }); - let groups = $derived.by(() => { - const result = new Map['launch'] }>(); - for (const server of filtered) { + const problems = $derived(filtered.filter(needsAttention)); + const live = $derived(filtered.filter(server => !needsAttention(server) && freshServer(server, observationNow()) && (server.last_players ?? 0) > 0)); + const idle = $derived(filtered.filter(server => !needsAttention(server) && freshServer(server, observationNow()) && server.last_players === 0)); + const preparing = $derived(filtered.filter(server => !needsAttention(server) && !live.includes(server) && !idle.includes(server))); + const liveCount = $derived(healthy.filter(server => (server.last_players ?? 0) > 0).length); + function grouped(items: FleetServer[]) { + const result = new Map['launch'] }>(); + for (const server of items) { 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, + ? identity.game === 't5' ? 'Black Ops I' : identity.game === 't6' ? 'Black Ops II' : 'Other servers' : '', servers: [], launch: grouping === 'map' ? identity.launch : null }); result.get(key)!.servers.push(server); } - return [...result.entries()].sort(([, a], [, b]) => a.title.localeCompare(b.title)); + // Preserve the selected ranking between groups as well as within them. + return [...result.entries()]; + } + const groups = $derived(grouped(filtered)); + const sections = $derived([ + { title: 'Live matches', servers: live, compact: false }, + { title: 'Preparing', servers: preparing, compact: true }, + { title: 'Empty lobbies', servers: idle, compact: true } + ].filter(section => section.servers.length)); + let announcement = $state(''); + let previousStates = new Map(); + $effect(() => { + const current = new Map(servers.map(server => [server.name, serverHealth(server, observationNow()).state])); + const changes = servers.filter(server => previousStates.has(server.name) && previousStates.get(server.name) !== current.get(server.name)); + if (changes.length) announcement = changes.slice(0, 3).map(server => `${serverTitle(server.name)}: ${serverHealth(server, observationNow()).label}.`).join(' ') + + (changes.length > 3 ? ` ${changes.length - 3} other servers changed status.` : ''); + previousStates = current; }); let spotlight = $derived(servers.filter(server => currentRound(server, $telemetryByServer.get(server.name), observationNow()) !== null) .sort((a, b) => matchDuration(b) - matchDuration(a))[0]); @@ -141,210 +163,195 @@ Fleet · Electric Cherry
- - {#snippet actions()} - - {#if canOperate}{/if} - {/snippet} - +
+ + {#if canOperate}{/if} +
+
{announcement}
{#if canOperate}{/if} - {#if $fleetError} - + {/if} - - {#if !$fleet.receivedAt && !data.fleetSnapshot} - {#if !$fleetError}
Loading your fleet…
{/if} - {:else} - {#if servers.length}
- {healthy.length ? players : '—'} players online + {#if servers.length} +
+ {healthy.length ? players : '—'} players · {liveCount} live {liveCount === 1 ? 'match' : 'matches'} {healthy.length}/{servers.length} healthy - - {campaigns} active campaigns - {$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago -
{/if} - {#if integrations?.native && !integrations.hostsReady} -
Prepare a game host · {integrations.hostsTotal ? 'Hosts are connected or enrolled but need setup before new deployments.' : 'Add a host, prepare its runtime, and download game files.'}Review hosts →
- {/if} - - {#if problems.length || attentionOperations.length || starting || provisioning} -
0 || attentionOperations.length > 0}> - -
- {#if problems.length} - - {:else}{provisioning ? `${provisioning} ${provisioning === 1 ? 'server is' : 'servers are'} provisioning` : servers.length ? starting ? `${starting} ${starting === 1 ? 'server is' : 'servers are'} starting` : 'All servers healthy' : 'Preparing your fleet'}{/if} - -
- {#if attentionOperations.length}{/if} + + {#if campaigns}{campaigns} active {campaigns === 1 ? 'campaign' : 'campaigns'}{/if} + {$fleetError ? 'Reconnecting…' : `Live · updated ${snapshotAge < 60 ? `${snapshotAge} s` : `${Math.floor(snapshotAge / 60)} min`} ago`} +
+ {/if} + {#if integrations?.native && !integrations.hostsReady} +
Prepare a game host · Connect a host, prepare its runtime, and download game files.Review hosts →
+ {/if} + {#if servers.length === 0} + + {#snippet actions()}{#if canOperate}{/if}{/snippet} + + {:else} + {#if view === 'cards' && problems.length} +
+

Needs attention {problems.length}

+ {#each problems as server (server.name)} + {@const status = serverHealth(server, observationNow())} + {@const operation = attentionOperations.find(item => item.server === server.name)} +
+
+ +

{operation && status.state === 'online' ? operation.label : status.reason}{#if status.since} · Evidence {elapsedSince(status.since, $uiNow)} ago{/if}

+ +
+ {/each} +
+ {/if} +
+
+ +
- {/if} - - {#if spotlight} - -
Longest running match{serverTitle(spotlight.name)}
- Round {currentRound(spotlight, $telemetryByServer.get(spotlight.name), $uiNow)} - {spotlight.last_players} {spotlight.last_players === 1 ? 'survivor' : 'survivors'} · {elapsedSince(spotlight.game_started_at, $uiNow)} -
- {/if} - - {#if servers.length === 0} - - {#snippet actions()} - {#if canOperate}{/if} - - {/snippet} - - {:else} -
-
- - -
- {#if viewOptionsOpen}
- - - -
-
{/if} -
-
{#each filters as item} - - {/each}
- -
-
Showing {filtered.length} of {servers.length} servers{#if query || filter !== 'all' || game}{/if}
+ {#if viewOptionsOpen}
+ + + +
+
{/if} +
+
{#each filters as item} + {@const count = servers.filter(server => matchesView(server, '', item.id)).length} + + {/each}
+ {#if query || filter !== 'all' || game}{/if}
- - {#if filtered.length === 0}{/if} - {#each groups as [key, group] (key)} -
- {#if grouping !== 'none'}
{#if grouping === 'map'}{group.game.toUpperCase()}{/if}

{group.title}

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

- {#if canOperate && group.launch}{/if} -
{/if} - {#if view === 'cards'} -
{#each group.servers as server (server.name)} -
- - {@render serverOperations(server)} -
{@render serverActions(server)}
-
- {/each}
- {:else} -
+ + {#if filtered.length === 0} + {#snippet actions()}{/snippet} + {/if} + {#if view === 'cards'} + {#each sections as section (section.title)} +
+

{section.title} {section.servers.length}

+ {#each grouped(section.servers) as [key, group] (key)} + {#if group.title}

{group.title}

{#if canOperate && group.launch}{/if}
{/if} +
{#each group.servers as server (server.name)} - {@const round = currentRound(server, $telemetryByServer.get(server.name), $uiNow)} -
- - - +
+ togglePin(server.name)} longestMatch={spotlight?.name === server.name} + mapLabelOverride={fleetIdentity(server).map} /> + {@render serverOperations(server)} +
{/each} -
{group.title} servers
LobbyStatusPlayersRound / matchOperationFollow
{server.health}{freshServer(server, $uiNow) ? server.last_players ?? '—' : '—'}{round !== null ? `Round ${round}` : '—'}{#if matchDuration(server) >= 0}{elapsedSince(server.game_started_at, $uiNow)}{/if}{@render serverOperations(server, true)}
{@render serverActions(server)}
- {/if} +
+ {/each}
{/each} + {:else if filtered.length} +
+ + {#each groups as [key, group] (key)} + {#if group.title}{/if} + {#each group.servers as server (server.name)} + {@const round = currentRound(server, $telemetryByServer.get(server.name), observationNow())} + + + + + {/each} + {/each} +
Fleet servers
ServerStatusPlayersRoundMatch timeOperationPin
{group.title} · {group.servers.length} {group.servers.length === 1 ? 'server' : 'servers'}
{freshServer(server, observationNow()) ? server.last_players ?? '—' : '—'}{round ?? '—'}{matchDuration(server) >= 0 ? elapsedSince(server.game_started_at, $uiNow) : '—'}{@render serverOperations(server)}
{/if} + {/if} -{#snippet serverOperations(server: FleetServer, showEmpty = false)} +{#snippet serverOperations(server: FleetServer)} {@const operations = activeOperations.filter(op => op.server === server.name)} {#if operations.length}
{#each operations as operation (operation.key)} - {operation.label} →{operation.detail} - {/each}
{:else if showEmpty}No pending work{/if} -{/snippet} - -{#snippet serverActions(server: FleetServer)} - - + {operation.label} → + {/each}
{/if} {/snippet} diff --git a/tests/unit/ui-server-card.test.ts b/tests/unit/ui-server-card.test.ts new file mode 100644 index 0000000..c8e7733 --- /dev/null +++ b/tests/unit/ui-server-card.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render } from 'svelte/server'; +import { get, writable } from 'svelte/store'; +import type { ComponentProps } from 'svelte'; +import ServerCard from '../../src/lib/ui/components/ServerCard.svelte'; +import { telemetryByServer, uiNow } from '../../src/lib/ui/stores/servers.js'; +import { gameEvent } from '../helpers/telemetry.js'; + +vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/?filter=occupied') }) })); +const name = 't5_zombie_theater_1'; +const at = (age: number) => new Date(get(uiNow) - age).toISOString(); +const telemetry = (round = 31, age = 1000) => ({ ...gameEvent('heartbeat', round, 4, name), receivedAt: at(age) }); +function html(props: Partial> = {}) { + return render(ServerCard, { props: { name, health: 'online', players: 4, capacity: 4, + telemetry: telemetry(), gameStartedAt: at(6_000_000), ...props } }).body.replaceAll(//g, ''); +} +afterEach(() => telemetryByServer.set(new Map())); + +describe('compact server cards', () => { + it('leads with identity and three labelled metrics, without healthy freshness or duplicate activity clocks', () => { + const body = html({ gametype: 'zom', activeSince: at(6_120_000), lastSeenAt: at(1000), longestMatch: true }); + expect(body).toContain('Kino Der Toten'); + expect(body).toContain('Lobby 1'); + expect(body).toContain('BO1'); + expect(body).toMatch(/]*>Players<\/dt>]*>4]*>\/4<\/span><\/dd>/); + expect(body).toMatch(/]*>Round<\/dt>]*>31<\/dd>/); + expect(body).toMatch(/]*>Match time<\/dt>]*>1h 40m<\/dd>/); + expect(body).toContain('Longest match'); + expect(body).not.toMatch(/Last seen|Last reported|Zombies Classic|>Active { + const body = html({ players: 2, capacity: null }); + expect(body).toContain('title="Capacity unavailable"'); + expect(body).toMatch(/>2]*>\/\?<\/span>/); + expect(body).not.toContain('/4'); + }); + + it('uses the newest fresh report while respecting explicit round data and match boundaries', () => { + telemetryByServer.set(new Map([[name, telemetry(35, 1000)]])); + expect(html({ telemetry: telemetry(31, 10_000) })).toMatch(/]*>Round<\/dt>]*>35<\/dd>/); + expect(html({ round: 40 })).toMatch(/]*>Round<\/dt>]*>40<\/dd>/); + expect(html({ round: null })).toMatch(/]*>Round<\/dt>]*>—<\/dd>/); + expect(html({ gameStartedAt: at(0) })).toMatch(/]*>Round<\/dt>]*>—<\/dd>/); + telemetryByServer.set(new Map()); + expect(html({ telemetry: telemetry(31, 100_000) })).toMatch(/]*>Round<\/dt>]*>—<\/dd>/); + expect(html({ gameEndedAt: at(1000), round: 40 })).toContain('Match ended'); + expect(html({ gameEndedAt: at(1000), round: 40 })).not.toMatch(/]*>40<\/dd>/); + }); + + it('gives the roster real column headers and formatted scores without repeating ping units', () => { + const body = html({ roster: [ + { num: 0, guid: 'one', name: 'Vexx', score: 99170, ping: 36 }, + { num: 1, guid: 'two', name: 'Connecting player', score: null, ping: 999 } + ] }); + expect(body).toContain('aria-label="Current players"'); + expect(body).toMatch(/]*scope="col"[^>]*>Player<\/th>/); + expect(body).toContain('99,170'); + expect(body).toContain('Connecting'); + expect(body).not.toContain('36ms'); + }); + + it('places Pin outside the only link and retains its pressed state and server name', () => { + const body = html({ pinned: true, onPin: () => {} }); + expect(body).toMatch(//)?.[0]).not.toContain(' { + const body = html({ players: 0, inactiveSince: at(9 * 60_000), roster: [], compactEmpty: true }); + expect(body).toContain('Ready'); + expect(body).toContain('Empty for 9m 00s'); + expect(body).not.toContain(' new Date(now + offset).toISOString(); +const server: FleetServer = { + name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater', + last_gametype: 'zclassic', last_players: 4, last_seen_at: iso(-10_000), last_error: null, + container_state: 'running', container_checked_at: iso(-5_000), last_checked_at: iso(-5_000), + roster: [], health: 'online', held: [], active_since: iso(-120_000), inactive_since: null, + game_started_at: iso(-120_000), game_ended_at: null, host_id: 'host-1', + host: { id: 'host-1', name: 'Dallas 1', connected: true, connectionState: 'online', lastHeartbeat: iso(-1_000) } +}; + +describe('server health presentation', () => { + it('uses current game evidence for Online and expires that evidence with the UI clock', () => { + expect(serverHealth(server, now)).toMatchObject({ state: 'online', since: server.last_seen_at }); + expect(serverHealth({ ...server, host: undefined, host_id: undefined }, now + 91_000)).toMatchObject({ + state: 'not-responding', actionHref: `/servers/${server.name}?tab=diagnostics`, since: server.last_seen_at + }); + }); + + it('identifies host loss without claiming the game stopped, including stale heartbeat snapshots', () => { + for (const host of [{ ...server.host!, connected: false, connectionState: 'offline' as const }, + { ...server.host!, lastHeartbeat: iso(-91_000) }]) { + expect(serverHealth({ ...server, host, health: 'offline', container_state: 'stopped' }, now)).toEqual({ + state: 'host-offline', label: 'Host offline', reason: "Dallas 1's host agent is unavailable. The game may still be running.", + actionLabel: 'Check host', actionHref: '/hosts/host-1', since: host.lastHeartbeat + }); + } + }); + + it.each(['stopped', 'exited', 'created', 'dead'])('only offers Start after a current %s container observation', container_state => { + expect(serverHealth({ ...server, health: 'offline', container_state }, now)).toMatchObject({ + state: 'stopped', since: server.container_checked_at, actionLabel: 'Start server', + actionHref: `/servers/${server.name}?action=start` + }); + }); + + it.each([ + { container_checked_at: iso(-91_000), last_checked_at: iso() }, + { container_checked_at: null, last_checked_at: iso() }, + { container_checked_at: 'invalid' }, + { container_checked_at: iso(30_000) }, + { telemetry_after: iso(-2_000) }, + { telemetry_after: 'invalid' }, + { last_seen_at: iso(-1_000) }, + { last_seen_at: iso(-5_000) }, + { host: null } + ])('does not treat outdated or conflicting stop evidence as a safe Start action: %j', change => { + const result = serverHealth({ ...server, health: 'offline', container_state: 'stopped', ...change }, now); + expect(result.state).toBe('unknown'); + expect(result.actionHref).not.toContain('action=start'); + }); + + it('separates a running container with failed game checks from a stopped or missing container', () => { + expect(serverHealth({ ...server, health: 'offline', last_seen_at: iso(-300_000), last_error: 'timeout:Internal endpoint 10.0.0.1 timed out' }, now)).toEqual({ + state: 'not-responding', label: 'Not responding', reason: 'The game did not answer the latest status check.', + actionLabel: 'Test connection', actionHref: `/servers/${server.name}?tab=diagnostics`, since: iso(-300_000) + }); + expect(serverHealth({ ...server, health: 'offline', container_state: 'missing', last_seen_at: null }, now).state).toBe('unknown'); + }); + + it('uses generic copy for unrecognized failures without exposing internal paths or prefixes', () => { + const result = serverHealth({ ...server, health: 'offline', last_error: 'legacy-failure:/private/server/secret', container_checked_at: null, last_seen_at: null }, now); + expect(result).toMatchObject({ state: 'unknown', since: null, reason: 'There is no current evidence of the game or container state.' }); + expect(result.reason).not.toContain('secret'); + }); + + it('retains explicit startup phases without mistaking old stopped observations for current state', () => { + for (const health of ['starting', 'provisioning'] as const) { + expect(serverHealth({ ...server, health, container_state: 'stopped', telemetry_after: iso(-2_000) }, now)).toMatchObject({ + state: health, actionLabel: 'View progress', since: iso(-2_000) + }); + } + }); + + it('rejects old-process and stale-container evidence for Online', () => { + expect(serverHealth({ ...server, telemetry_after: iso(-2_000) }, now).state).toBe('unknown'); + expect(serverHealth({ ...server, container_checked_at: iso(-91_000) }, now).state).not.toBe('online'); + }); + + it('uses only valid evidence times and encodes navigation identities', () => { + expect(serverHealth({ ...server, name: 'server / 1', health: 'offline', container_checked_at: null, last_seen_at: iso(30_000), host: null, host_id: 'host / 1' }, now)).toMatchObject({ + state: 'unknown', actionHref: '/hosts/host%20%2F%201', since: null + }); + expect(serverHealth({ ...server, name: 'server / 1', health: 'offline', container_state: 'unknown', host: undefined, host_id: undefined }, now).actionHref) + .toBe('/servers/server%20%2F%201?tab=diagnostics'); + }); +}); diff --git a/tests/unit/ui-status-accessibility.test.ts b/tests/unit/ui-status-accessibility.test.ts index e67b332..b5c8204 100644 --- a/tests/unit/ui-status-accessibility.test.ts +++ b/tests/unit/ui-status-accessibility.test.ts @@ -47,12 +47,12 @@ describe('status accessibility', () => { roster: [{ num: 0, name: 'Player One', score: 30000, ping: 40, guid: '1' }] } }).body; const card = body.match(/]*>/)?.[0] ?? ''; - const titleId = card.match(/aria-labelledby="([^"]+)"/)?.[1]; + const titleId = card.match(/aria-labelledby="([^"]+)"/)?.[1]?.split(' ')[0]; expect(titleId).toBeTruthy(); expect(body).toContain(`id="${titleId}"`); expect(card).not.toContain('aria-label='); expect(body).toContain('Online'); - expect(body).toContain('Round 30'); + expect(body).toMatch(/]*>Round<\/dt>]*>30<\/dd>/); expect(body).toContain('Player One'); expect(body).not.toMatch(/aria-live=|role="status"/); });