Rebuild Fleet around attention and compact live matches
This commit is contained in:
@@ -8,6 +8,8 @@ import { mapTitle, serverTitle } from './ui/server-labels.js';
|
|||||||
|
|
||||||
export interface FleetServer {
|
export interface FleetServer {
|
||||||
plutonium?: import('./plutonium.js').PlutoniumVersion;
|
plutonium?: import('./plutonium.js').PlutoniumVersion;
|
||||||
|
host_id?: string | null;
|
||||||
|
host?: Pick<import('./hosts.js').Host, 'id' | 'name' | 'connectionState' | 'connected' | 'lastHeartbeat'> | null;
|
||||||
container_state?: string | null;
|
container_state?: string | null;
|
||||||
container_checked_at?: string | null;
|
container_checked_at?: string | null;
|
||||||
last_checked_at?: string | null;
|
last_checked_at?: string | null;
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { contextHref } from '$lib/navigation-context.js';
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
import RoundTelemetry from './RoundTelemetry.svelte';
|
import { freshStatusTimestamp } from '$lib/server-freshness.js';
|
||||||
import ServerIdentity from './ServerIdentity.svelte';
|
|
||||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||||
import { uiNow } from '$ui/stores/servers.js';
|
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
||||||
import { gametypeLabel } from '$ui/gametype.js';
|
|
||||||
import { elapsedSince } from '$ui/duration.js';
|
import { elapsedSince } from '$ui/duration.js';
|
||||||
import { mapTitle, serverIdentity } from '$ui/server-labels.js';
|
import { mapTitle, serverIdentity, serverTitle } from '$ui/server-labels.js';
|
||||||
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
|
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
|
||||||
import Card from './primitives/Card.svelte';
|
import Card from './primitives/Card.svelte';
|
||||||
import Badge from './primitives/Badge.svelte';
|
import Badge from './primitives/Badge.svelte';
|
||||||
import Stat from './primitives/Stat.svelte';
|
|
||||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||||
|
|
||||||
interface Held {
|
interface Held { target: string; betaId: number; }
|
||||||
target: string;
|
|
||||||
betaId: number;
|
|
||||||
}
|
|
||||||
interface Props {
|
interface Props {
|
||||||
telemetry?: import('$lib/telemetry.js').GameTelemetry;
|
telemetry?: import('$lib/telemetry.js').GameTelemetry;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -37,316 +31,143 @@
|
|||||||
gameEndedAt?: string | null;
|
gameEndedAt?: string | null;
|
||||||
compactEmpty?: boolean;
|
compactEmpty?: boolean;
|
||||||
mapLabelOverride?: string;
|
mapLabelOverride?: string;
|
||||||
|
round?: number | null;
|
||||||
|
capacity?: number | null;
|
||||||
|
pinned?: boolean;
|
||||||
|
onPin?: () => void;
|
||||||
|
longestMatch?: boolean;
|
||||||
}
|
}
|
||||||
let {
|
let {
|
||||||
telemetry,
|
telemetry, name, players = null, map = null, roster = null, health = 'offline',
|
||||||
name,
|
lastSeenAt = null, lastError = null, held = [], restarting = false,
|
||||||
players = null,
|
inactiveSince = null, gameStartedAt = null, gameEndedAt = null, compactEmpty = true,
|
||||||
map = null,
|
mapLabelOverride, round, capacity = null, pinned = false, onPin, longestMatch = false
|
||||||
gametype = null,
|
|
||||||
roster = null,
|
|
||||||
health = 'offline',
|
|
||||||
lastSeenAt = null,
|
|
||||||
lastError = null,
|
|
||||||
held = [],
|
|
||||||
lastRestartedAt = null,
|
|
||||||
restarting = false,
|
|
||||||
activeSince = null,
|
|
||||||
inactiveSince = null,
|
|
||||||
gameStartedAt = null,
|
|
||||||
gameEndedAt = null,
|
|
||||||
compactEmpty = false,
|
|
||||||
mapLabelOverride
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
const id = $props.id();
|
const id = $props.id();
|
||||||
|
const number = new Intl.NumberFormat('en-US');
|
||||||
function ago(iso: string | null, now: number): string {
|
const identity = $derived(serverIdentity(name));
|
||||||
if (!iso) return 'never';
|
const title = $derived(mapLabelOverride ?? identity.map);
|
||||||
const d = now - new Date(iso).getTime();
|
const game = $derived(identity.game === 'Black Ops I' ? 'BO1' : identity.game === 'Black Ops II' ? 'BO2' : identity.game);
|
||||||
if (d < 60_000) return `${Math.max(1, Math.floor(d / 1000))}s ago`;
|
const compact = $derived(compactEmpty && health === 'online' && players === 0 && !restarting);
|
||||||
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
|
const state = $derived(restarting ? 'restarting' : compact ? 'ready' : health);
|
||||||
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
|
|
||||||
return `${Math.floor(d / 86_400_000)}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let topRoster = $derived(
|
|
||||||
(roster ?? [])
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
||||||
.slice(0, 4)
|
|
||||||
);
|
|
||||||
let showRoster = $derived(health === 'online' || health === 'stale');
|
|
||||||
const state = $derived(restarting ? 'restarting' : health);
|
|
||||||
const error = $derived(decodeRconError(lastError));
|
const error = $derived(decodeRconError(lastError));
|
||||||
let mapLabel = $derived(mapLabelOverride ?? mapTitle(map));
|
const mapLabel = $derived(mapTitle(map));
|
||||||
const showMap = $derived(mapLabel !== '—' && mapLabel !== serverIdentity(name).map);
|
const showMap = $derived(mapLabel !== '—' && mapLabel !== title);
|
||||||
let gtLabel = $derived(gametypeLabel(gametype));
|
const topRoster = $derived((roster ?? []).slice().sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, 4));
|
||||||
|
const report = $derived.by(() => {
|
||||||
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
|
const streamed = $telemetryByServer.get(name);
|
||||||
let resetting = $derived(!!gameEndedAt && players !== 0);
|
return streamed && (!telemetry || Date.parse(streamed.receivedAt) > Date.parse(telemetry.receivedAt)) ? streamed : telemetry;
|
||||||
let resetElapsed = $derived(resetting ? elapsedSince(gameEndedAt, $uiNow) : null);
|
});
|
||||||
let inactiveElapsed = $derived(inactiveSince ? elapsedSince(inactiveSince, $uiNow) : null);
|
const reportedRound = $derived(report && (report.event === 'heartbeat' || report.event === 'round_change')
|
||||||
let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt && !!activeSince);
|
&& freshStatusTimestamp(report.receivedAt, $uiNow)
|
||||||
let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null);
|
&& (!gameStartedAt || Date.parse(report.receivedAt) >= Date.parse(gameStartedAt))
|
||||||
let showTiming = $derived(
|
? report.round : null);
|
||||||
health === 'online' && (resetting || activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
|
const currentRound = $derived(!gameEndedAt ? (round !== undefined ? round : reportedRound) : null);
|
||||||
);
|
const gameElapsed = $derived(gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null);
|
||||||
|
const emptyElapsed = $derived(elapsedSince(inactiveSince, $uiNow));
|
||||||
// Shared-element transition to /servers/{name} — Chromium only, others
|
const knownCapacity = $derived(capacity !== null && Number.isFinite(capacity) && capacity > 0 ? capacity : null);
|
||||||
// fall through to a plain nav.
|
const cardStyle = $derived(`view-transition-name: server-${name.replace(/[^a-z0-9_-]/gi, '-')};`);
|
||||||
let cardStyle = $derived(`view-transition-name: server-${name.replace(/[^a-z0-9_-]/gi, '-')};`);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card
|
<Card as="article" padding={compact ? 'sm' : 'md'} class={`server-card ${compact ? 'compact' : ''} ${health === 'offline' ? 'offline' : ''}`} ariaLabelledby={`${id}-title`} style={cardStyle}>
|
||||||
href={contextHref(`/servers/${encodeURIComponent(name)}`, $page.url)}
|
|
||||||
elevation="interactive"
|
|
||||||
padding="md"
|
|
||||||
class={restarting ? 'restarting' : health === 'offline' ? 'offline' : ''}
|
|
||||||
ariaLabelledby={`${id}-title`}
|
|
||||||
style={cardStyle}
|
|
||||||
>
|
|
||||||
{#if restarting}
|
|
||||||
<span class="restart-bar" aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div class="name-row">
|
<a class="identity" href={contextHref(`/servers/${encodeURIComponent(name)}`, $page.url)} aria-labelledby={`${id}-title${identity.lobby !== null ? ` ${id}-lobby` : ''}`}>
|
||||||
<strong class="name" id={`${id}-title`}><ServerIdentity {name} mapLabel={mapLabelOverride} /></strong>
|
<h3 id={`${id}-title`}>{title}</h3>
|
||||||
</div>
|
<span class="identity-meta">
|
||||||
<div class="status">
|
{#if identity.lobby !== null}<span id={`${id}-lobby`}>Lobby {identity.lobby}</span>{/if}
|
||||||
|
{#if game}<span class="game" title={identity.game}>{game}</span>{/if}
|
||||||
|
{#if compact}<span class="empty-duration">{emptyElapsed ? `Empty for ${emptyElapsed}` : 'Empty lobby'}</span>{/if}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div class="head-actions">
|
||||||
<StatusBadge {state} />
|
<StatusBadge {state} />
|
||||||
|
{#if onPin}<button class="pin" type="button" aria-pressed={pinned} aria-label={`${pinned ? 'Unpin' : 'Pin'} ${serverTitle(name)}`} title={pinned ? 'Unpin server' : 'Pin server'} onclick={onPin}><span aria-hidden="true">{pinned ? '★' : '☆'}</span></button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !compact}
|
||||||
|
{#if longestMatch && health === 'online'}<div class="highlight"><Badge>Longest match</Badge></div>{/if}
|
||||||
|
{#if showMap}<p class="map-note">{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</p>{/if}
|
||||||
{#if health === 'online'}
|
{#if health === 'online'}
|
||||||
<div class="players">
|
<dl class="metrics">
|
||||||
<Stat
|
<div><dt>Players</dt><dd title={knownCapacity === null ? 'Capacity unavailable' : `${knownCapacity} player capacity`}>{players ?? '—'}<span class="capacity">/{knownCapacity ?? '?'}</span></dd></div>
|
||||||
value={players ?? '—'}
|
<div><dt>Round</dt><dd>{currentRound ?? '—'}</dd></div>
|
||||||
size="md"
|
<div><dt>Match time</dt><dd>{gameElapsed ?? '—'}</dd></div>
|
||||||
tone={players !== null && players > 0 ? 'online' : 'primary'}
|
</dl>
|
||||||
tween
|
{#if gameEndedAt && players !== 0}<p class="match-note">Match ended · waiting for the next match</p>{/if}
|
||||||
/>
|
{:else if health === 'stale'}
|
||||||
</div>
|
<p class="problem-note"><strong>Current player count unknown.</strong> Last reported: {players ?? 'unknown'}{players !== null ? ` ${players === 1 ? 'player' : 'players'}` : ''}{lastSeenAt ? ` · ${elapsedSince(lastSeenAt, $uiNow)} ago` : ''}.</p>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if gtLabel || showMap}<div class="meta">{#if showMap}<span>{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</span>{/if}{#if gtLabel}<span class="gt">{gtLabel}</span>{/if}</div>{/if}
|
|
||||||
|
|
||||||
{#if health === 'stale'}
|
|
||||||
<div class="stale-note"><strong>Players unknown</strong><span>Last known: {players ?? 'unknown'}{players !== null ? ' players' : ''}{lastSeenAt ? ` · ${ago(lastSeenAt, $uiNow)}` : ''}</span></div>
|
|
||||||
{#if topRoster.length}<div class="roster-heading">Last reported players</div>{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} report={telemetry} since={gameStartedAt} />{/if}
|
{#if health === 'online' || health === 'stale'}
|
||||||
|
{#if topRoster.length}
|
||||||
{#if showTiming && !(compactEmpty && players === 0)}
|
<div class="roster">
|
||||||
<div class="timing">
|
<table aria-label={health === 'stale' ? 'Last reported players' : 'Current players'}>
|
||||||
{#if resetting}
|
<thead><tr><th scope="col">Player</th><th scope="col">Score</th><th scope="col">Ping <span class="ping-unit">(ms)</span></th></tr></thead>
|
||||||
<div class="metric">
|
<tbody>{#each topRoster as player (player.guid ?? player.num)}<tr>
|
||||||
<span class="metric-label">Resetting</span>
|
<td title={player.name ?? undefined}>{player.name || '—'}</td>
|
||||||
<span class="metric-value">{resetElapsed}</span>
|
<td>{player.score === null ? '—' : number.format(player.score)}</td>
|
||||||
</div>
|
<td>{player.ping === 999 ? 'Connecting' : player.ping === null ? '—' : number.format(player.ping)}</td>
|
||||||
{:else if activeElapsed}
|
</tr>{/each}</tbody>
|
||||||
<div class="metric">
|
</table>
|
||||||
<span class="metric-label">Active</span>
|
|
||||||
<span class="metric-value">{activeElapsed}</span>
|
|
||||||
</div>
|
|
||||||
{:else if inactiveElapsed}
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Inactive</span>
|
|
||||||
<span class="metric-value">{inactiveElapsed}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if gameElapsed}
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Match</span>
|
|
||||||
<span class="metric-value">{gameElapsed}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{:else if players !== 0}<p class="roster-unavailable">{health === 'stale' ? 'Waiting for a fresh report' : 'Player roster unavailable'}</p>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showRoster}
|
{#if health === 'offline'}
|
||||||
{#if topRoster.length > 0}
|
<p class="problem-note" title={error ? rconFixHint(error.reason) : undefined}>{error?.detail || 'Server status is unavailable. Open diagnostics for details.'}</p>
|
||||||
<ul class="roster">
|
{#if lastSeenAt}<p class="last-report">Last report {elapsedSince(lastSeenAt, $uiNow)} ago</p>{/if}
|
||||||
{#each topRoster as p (p.guid ?? p.name)}
|
|
||||||
<li>
|
|
||||||
<span class="pname">{p.name || '—'}</span>
|
|
||||||
<span class="pscore">{p.score ?? '—'}</span>
|
|
||||||
<span class="pping">{p.ping === 999 ? 'Connecting' : p.ping === null ? '—' : `${p.ping}ms`}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<div class="lobby-empty">{health === 'stale' ? 'Waiting for a fresh report' : players === 0 ? 'Lobby is empty' : 'Player roster unavailable'}</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if held.length > 0}
|
{#if held.length > 0}<div class="held">{#each held as h (h.target)}<Badge variant="info" title={`Held by beta ${h.betaId}`}>β {h.target}</Badge>{/each}</div>{/if}
|
||||||
<div class="held">
|
|
||||||
{#each held as h (h.target)}
|
|
||||||
<Badge variant="info" size="sm" title="held by beta {h.betaId}">β {h.target}</Badge>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="foot">
|
|
||||||
{#if health === 'offline' && lastError}
|
|
||||||
<span class="err" title={error ? rconFixHint(error.reason) : undefined}>{error?.detail || 'Server status is unavailable. Open diagnostics for details.'}</span>
|
|
||||||
{:else if lastSeenAt}
|
|
||||||
<span>Last seen {ago(lastSeenAt, $uiNow)}</span>
|
|
||||||
{:else if lastRestartedAt}
|
|
||||||
<span>Restart {ago(lastRestartedAt, $uiNow)}</span>
|
|
||||||
{:else}
|
|
||||||
<span class="dim">No data</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.stale-note { display: grid; gap: 4px; margin-top: 14px; font-size: var(--text-sm); color: var(--text-secondary); }
|
:global(.card.server-card) { gap: var(--space-3); transition: border-color var(--dur-fast) var(--ease-standard); }
|
||||||
.stale-note strong { color: var(--text-primary); font-weight: var(--weight-medium); }
|
:global(.card.server-card:hover) { border-color: var(--border-strong); }
|
||||||
.roster-heading { margin-top: 14px; font-size: var(--text-xs); color: var(--text-tertiary); }
|
:global(.card.server-card.offline) { border-color: color-mix(in srgb, var(--state-danger) 35%, var(--border-subtle)); }
|
||||||
/* Global tweaks scoped through :global() since Card is a child component. */
|
.head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-2); }
|
||||||
:global(.card.restarting) {
|
.identity { min-width: 0; color: inherit; text-decoration: none; display: flex; flex-direction: column; gap: var(--space-1); }
|
||||||
outline: 1px solid rgba(255, 176, 32, 0.28);
|
.identity::after { content: ''; position: absolute; inset: 0; border-radius: var(--radius-lg); }
|
||||||
|
.identity:focus-visible { outline: none; }
|
||||||
|
.identity:focus-visible::after { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||||
|
h3 { margin: 0; font-size: var(--text-md); font-weight: var(--weight-semibold); line-height: var(--leading-tight); overflow-wrap: anywhere; }
|
||||||
|
.identity-meta { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||||
|
.game { background: var(--surface-raised); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 2px 5px; white-space: nowrap; font-weight: var(--weight-semibold); }
|
||||||
|
.head-actions { display: flex; align-items: center; flex-shrink: 0; gap: var(--space-1); }
|
||||||
|
.pin { position: relative; z-index: 1; display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; margin: -4px -6px -4px 0; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); cursor: pointer; font-size: var(--text-lg); }
|
||||||
|
.pin:hover, .pin[aria-pressed='true'] { color: var(--accent-primary-hover); background: var(--surface-raised); }
|
||||||
|
.pin:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||||
|
.metrics { display: grid; grid-template-columns: 1fr 1fr minmax(0, 1.5fr); gap: var(--space-3); margin: 0; }
|
||||||
|
.metrics > div { display: flex; flex-direction: column-reverse; align-items: flex-start; gap: var(--space-1); }
|
||||||
|
dt { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
|
dd { margin: 0; font-family: var(--font-numeric); font-variant-numeric: tabular-nums; font-size: var(--text-xl); font-weight: var(--weight-semibold); line-height: var(--leading-tight); white-space: nowrap; }
|
||||||
|
dd[title] { position: relative; z-index: 1; cursor: help; }
|
||||||
|
.capacity { font-size: var(--text-sm); font-weight: var(--weight-regular); color: var(--text-secondary); }
|
||||||
|
.roster { border-top: 1px solid var(--border-subtle); padding-top: var(--space-2); }
|
||||||
|
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
|
||||||
|
th { font-size: var(--text-xs); color: var(--text-secondary); font-weight: var(--weight-regular); padding: 2px 0 6px; }
|
||||||
|
td { padding: 3px 0; }
|
||||||
|
th, td { text-align: right; }
|
||||||
|
th:first-child, td:first-child { width: 52%; text-align: left; padding-right: var(--space-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
td:not(:first-child) { color: var(--text-secondary); }
|
||||||
|
.ping-unit { font-size: var(--text-xs); }
|
||||||
|
.map-note, .problem-note, .last-report, .roster-unavailable, .match-note { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
|
||||||
|
.problem-note strong { color: var(--text-primary); font-weight: var(--weight-medium); }
|
||||||
|
.last-report, .match-note { font-size: var(--text-xs); }
|
||||||
|
.held { display: flex; flex-wrap: wrap; gap: var(--space-1); }
|
||||||
|
:global(.compact) .head { align-items: center; }
|
||||||
|
:global(.compact) .identity { flex-direction: row; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-3); }
|
||||||
|
:global(.compact) h3 { font-size: var(--text-base); }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
:global(.card.server-card) { padding: var(--space-3); gap: var(--space-2); }
|
||||||
|
.roster, .roster-unavailable { display: none; }
|
||||||
|
.pin { width: 44px; height: 44px; margin: -8px -6px -8px 0; }
|
||||||
|
h3 { font-size: var(--text-base); }
|
||||||
|
dd { font-size: var(--text-lg); }
|
||||||
|
.head-actions { gap: 0; }
|
||||||
|
.metrics { gap: var(--space-2); }
|
||||||
|
:global(.compact) .identity { flex-direction: column; align-items: flex-start; gap: var(--space-1); }
|
||||||
}
|
}
|
||||||
:global(.card.offline) {
|
|
||||||
border-color: color-mix(in srgb, var(--state-danger) 25%, var(--border-subtle));
|
|
||||||
}
|
|
||||||
|
|
||||||
.restart-bar {
|
|
||||||
position: absolute;
|
|
||||||
top: 0; left: 0; right: 0;
|
|
||||||
height: 2px;
|
|
||||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent,
|
|
||||||
var(--state-warning),
|
|
||||||
var(--state-online),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: spark-slide 1.6s var(--ease-linear) infinite;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.name-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.name {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-md);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.players { flex-shrink: 0; }
|
|
||||||
.status { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--space-2); }
|
|
||||||
|
|
||||||
.meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gt { color: var(--text-tertiary); }
|
|
||||||
|
|
||||||
.timing {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-5);
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
.metric {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.metric-label {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: var(--tracking-label);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
.metric-value {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roster {
|
|
||||||
list-style: none;
|
|
||||||
padding: var(--space-3) 0 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1);
|
|
||||||
border-top: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
.roster li {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto auto;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-items: center;
|
|
||||||
padding: 2px 0;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.pname {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: var(--weight-regular);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.pscore,
|
|
||||||
.pping {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.pping { min-width: 44px; text-align: right; }
|
|
||||||
|
|
||||||
.lobby-empty {
|
|
||||||
padding-top: var(--space-3);
|
|
||||||
border-top: 1px solid var(--border-subtle);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.held {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.foot {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: var(--space-2);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
.err { color: var(--state-danger); }
|
|
||||||
.dim { color: var(--text-quaternary); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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<ServerHealthPresentation, 'label'> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
+192
-185
@@ -10,15 +10,15 @@
|
|||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { afterNavigate, replaceState } from '$app/navigation';
|
import { afterNavigate, replaceState } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import RuntimeVersion from '$ui/components/RuntimeVersion.svelte';
|
|
||||||
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 Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
import ServerIdentity from '$ui/components/ServerIdentity.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 StatusPip from '$ui/components/primitives/StatusPip.svelte';
|
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||||
import { fleet, fleetServers, fleetError, fleetRefreshing, fleetIntegrations, refreshFleet } from '$ui/stores/fleet.js';
|
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 { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
||||||
import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js';
|
import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js';
|
||||||
import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js';
|
import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js';
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
let query = $state('');
|
let query = $state('');
|
||||||
let filter = $state<FleetFilter>('all');
|
let filter = $state<FleetFilter>('all');
|
||||||
let game = $state('');
|
let game = $state('');
|
||||||
let sort = $state<FleetSort>('name');
|
let sort = $state<FleetSort>('duration');
|
||||||
let view = $state<'cards' | 'table'>('cards');
|
let view = $state<'cards' | 'table'>('cards');
|
||||||
let grouping = $state<'map' | 'game' | 'none'>('none');
|
let grouping = $state<'map' | 'game' | 'none'>('none');
|
||||||
let viewOptionsOpen = $state(false);
|
let viewOptionsOpen = $state(false);
|
||||||
@@ -41,29 +41,24 @@
|
|||||||
let initialMapSlug = $state('');
|
let initialMapSlug = $state('');
|
||||||
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
|
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
|
||||||
const filters: { id: FleetFilter; label: string }[] = [
|
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' }
|
{ id: 'attention', label: 'Needs attention' }, { id: 'testing', label: 'Testing' }, { id: 'pinned', label: 'Pinned' }
|
||||||
];
|
];
|
||||||
|
|
||||||
function readViewUrl() {
|
function readViewUrl() {
|
||||||
const params = new URL(window.location.href).searchParams;
|
const params = new URL(window.location.href).searchParams;
|
||||||
query = params.get('q') ?? '';
|
query = params.get('q') ?? '';
|
||||||
if (params.get('view') === 'cards' || params.get('view') === 'table') view = params.get('view') as typeof view;
|
view = params.get('view') === 'table' ? 'table' : 'cards';
|
||||||
if (['map', 'game', 'none'].includes(params.get('group') ?? '')) grouping = params.get('group') as typeof grouping;
|
grouping = ['map', 'game'].includes(params.get('group') ?? '') ? params.get('group') as typeof grouping : 'none';
|
||||||
if (['name', 'players', 'round', 'duration'].includes(params.get('sort') ?? '')) sort = params.get('sort') as FleetSort;
|
sort = ['name', 'players', 'round', 'duration'].includes(params.get('sort') ?? '') ? params.get('sort') as FleetSort : 'duration';
|
||||||
if (filters.some(item => item.id === params.get('filter'))) filter = params.get('filter') as FleetFilter;
|
filter = filters.some(item => item.id === params.get('filter')) ? params.get('filter') as FleetFilter : 'all';
|
||||||
if (params.has('game') && ['', 't5', 't6'].includes(params.get('game')!)) game = params.get('game')!;
|
game = ['t5', 't6'].includes(params.get('game') ?? '') ? params.get('game')! : '';
|
||||||
}
|
}
|
||||||
afterNavigate(() => { routerReady = true; if (preferencesReady) readViewUrl(); });
|
afterNavigate(() => { routerReady = true; if (preferencesReady) readViewUrl(); });
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem('electric-cherry:fleet') ?? '{}');
|
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');
|
if (Array.isArray(saved.pinned)) pinned = saved.pinned.filter((name: unknown) => typeof name === 'string');
|
||||||
} catch { /* Preferences also work when storage is disabled. */ }
|
} catch { /* Preferences also work when storage is disabled. */ }
|
||||||
readViewUrl();
|
readViewUrl();
|
||||||
@@ -73,13 +68,16 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!preferencesReady) return;
|
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. */ }
|
catch { /* The current view still works without persistent storage. */ }
|
||||||
const values = { q: query, view, group: grouping, sort, filter, game };
|
const values = { q: query, view, group: grouping, sort, filter, game };
|
||||||
if (!routerReady) return;
|
if (!routerReady) return;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
for (const [key, value] of Object.entries(values)) url.searchParams.set(key, value);
|
const defaults: Record<string, string> = { 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);
|
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 activeOperations = $derived(snapshot.operations.filter(op => op.state !== 'done'));
|
||||||
let attentionOperations = $derived(activeOperations.filter(op => op.state === 'attention'));
|
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 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 players = $derived(healthy.reduce((sum, server) => sum + (server.last_players ?? 0), 0));
|
||||||
let empty = $derived(healthy.filter(server => server.last_players === 0).length);
|
let empty = $derived(healthy.filter(server => server.last_players === 0).length);
|
||||||
let problems = $derived(servers.filter(server => server.health === 'offline' || server.health === 'stale'));
|
const needsAttention = (server: FleetServer) => !['online', 'starting', 'provisioning'].includes(serverHealth(server, observationNow()).state) || activeOperations.some(op => op.server === server.name && op.state === 'attention');
|
||||||
let starting = $derived(servers.filter(server => server.health === 'starting').length);
|
function matchesView(server: FleetServer, search: string, selected: FleetFilter) {
|
||||||
let provisioning = $derived(servers.filter(server => server.health === 'provisioning').length);
|
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(() => {
|
let filtered = $derived.by(() => {
|
||||||
const now = observationNow();
|
const now = observationNow();
|
||||||
const pinnedNames = new Set(pinned);
|
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 => ({
|
.map(server => ({
|
||||||
server,
|
server,
|
||||||
title: serverTitle(server.name),
|
title: serverTitle(server.name),
|
||||||
@@ -109,20 +110,41 @@
|
|||||||
: sort === 'round' ? currentRound(server, $telemetryByServer.get(server.name), now) ?? -1
|
: sort === 'round' ? currentRound(server, $telemetryByServer.get(server.name), now) ?? -1
|
||||||
: sort === 'duration' ? matchDuration(server, now) : 0
|
: 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);
|
.map(({ server }) => server);
|
||||||
});
|
});
|
||||||
let groups = $derived.by(() => {
|
const problems = $derived(filtered.filter(needsAttention));
|
||||||
const result = new Map<string, { title: string; game: string; servers: FleetServer[]; launch: ReturnType<typeof fleetIdentity>['launch'] }>();
|
const live = $derived(filtered.filter(server => !needsAttention(server) && freshServer(server, observationNow()) && (server.last_players ?? 0) > 0));
|
||||||
for (const server of filtered) {
|
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<string, { title: string; servers: FleetServer[]; launch: ReturnType<typeof fleetIdentity>['launch'] }>();
|
||||||
|
for (const server of items) {
|
||||||
const identity = fleetIdentity(server);
|
const identity = fleetIdentity(server);
|
||||||
const key = grouping === 'map' ? identity.group : grouping === 'game' ? identity.game : 'all';
|
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'
|
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 });
|
servers: [], launch: grouping === 'map' ? identity.launch : null });
|
||||||
result.get(key)!.servers.push(server);
|
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<string, string>();
|
||||||
|
$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)
|
let spotlight = $derived(servers.filter(server => currentRound(server, $telemetryByServer.get(server.name), observationNow()) !== null)
|
||||||
.sort((a, b) => matchDuration(b) - matchDuration(a))[0]);
|
.sort((a, b) => matchDuration(b) - matchDuration(a))[0]);
|
||||||
@@ -141,210 +163,195 @@
|
|||||||
<svelte:head><title>Fleet · Electric Cherry</title></svelte:head>
|
<svelte:head><title>Fleet · Electric Cherry</title></svelte:head>
|
||||||
|
|
||||||
<section class="page">
|
<section class="page">
|
||||||
<SectionHeader title="Fleet" subtitle="Live health, players, and server operations.">
|
<div class="page-heading">
|
||||||
{#snippet actions()}
|
<SectionHeader title="Fleet" />
|
||||||
<Button variant="ghost" size="sm" loading={$fleetRefreshing} onclick={() => void refreshFleet()}>Refresh</Button>
|
|
||||||
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create server</Button>{/if}
|
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create server</Button>{/if}
|
||||||
{/snippet}
|
</div>
|
||||||
</SectionHeader>
|
<div class="sr-only" role="status" aria-atomic="true">{announcement}</div>
|
||||||
{#if canOperate}<DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} />{/if}
|
{#if canOperate}<DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} />{/if}
|
||||||
|
|
||||||
{#if $fleetError}
|
{#if $fleetError}
|
||||||
<div class="error" role="alert"><strong>{$fleet.receivedAt ? 'Showing the last available snapshot.' : 'Fleet is unavailable.'}</strong> {$fleetError}
|
<div class="error" role="alert"><strong>Reconnecting…</strong> {$fleetError} Showing the last available snapshot.
|
||||||
<button onclick={() => void refreshFleet()}>Retry now</button></div>
|
<button onclick={() => void refreshFleet()}>Retry</button></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if servers.length}
|
||||||
{#if !$fleet.receivedAt && !data.fleetSnapshot}
|
<div class="overview" aria-label="Fleet summary">
|
||||||
{#if !$fleetError}<div class="loading" role="status">Loading your fleet…</div>{/if}
|
<span><strong>{healthy.length ? players : '—'}</strong> players · <strong>{liveCount}</strong> live {liveCount === 1 ? 'match' : 'matches'}</span>
|
||||||
{:else}
|
|
||||||
{#if servers.length}<div class="overview" aria-label="Fleet summary">
|
|
||||||
<span><strong>{healthy.length ? players : '—'}</strong> players online</span>
|
|
||||||
<span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
|
<span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
|
||||||
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}><strong>{empty}</strong> empty lobbies</button>
|
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}>{empty} empty {empty === 1 ? 'lobby' : 'lobbies'}</button>
|
||||||
<a class="metric-link" class:no-campaigns={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active campaigns</a>
|
{#if campaigns}<a class="metric-link" href="/campaigns">{campaigns} active {campaigns === 1 ? 'campaign' : 'campaigns'}</a>{/if}
|
||||||
<span class="freshness">{$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago</span>
|
<span class="freshness">{$fleetError ? 'Reconnecting…' : `Live · updated ${snapshotAge < 60 ? `${snapshotAge} s` : `${Math.floor(snapshotAge / 60)} min`} ago`}</span>
|
||||||
</div>{/if}
|
</div>
|
||||||
|
{/if}
|
||||||
{#if integrations?.native && !integrations.hostsReady}
|
{#if integrations?.native && !integrations.hostsReady}
|
||||||
<div class="setup-note"><span><strong>Prepare a game host</strong> · {integrations.hostsTotal ? 'Hosts are connected or enrolled but need setup before new deployments.' : 'Add a host, prepare its runtime, and download game files.'}</span><a href="/hosts">Review hosts →</a></div>
|
<div class="setup-note"><span><strong>Prepare a game host</strong> · Connect a host, prepare its runtime, and download game files.</span><a href="/hosts">Review hosts →</a></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if problems.length || attentionOperations.length || starting || provisioning}
|
|
||||||
<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>{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'}</strong>{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{#if attentionOperations.length}<button onclick={() => { resetFilters(); filter = 'attention'; }}>{attentionOperations.length} operation {attentionOperations.length === 1 ? 'needs' : 'need'} attention →</button>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if spotlight}
|
|
||||||
<a class="spotlight" href={contextHref(`/servers/${encodeURIComponent(spotlight.name)}`, $page.url)}>
|
|
||||||
<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 servers.length === 0}
|
{#if servers.length === 0}
|
||||||
<EmptyState title="No servers deployed">
|
<EmptyState title="No servers yet" description="Create a server to see live health, players, and matches here.">
|
||||||
{#snippet actions()}
|
{#snippet actions()}{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create server</Button>{/if}{/snippet}
|
||||||
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create a server</Button>{/if}
|
|
||||||
<Button href="/collections" variant="ghost">Browse collections</Button>
|
|
||||||
{/snippet}
|
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if view === 'cards' && problems.length}
|
||||||
|
<section class="attention" aria-labelledby="attention-heading">
|
||||||
|
<h2 id="attention-heading"><span aria-hidden="true">▲</span> Needs attention <span class="count">{problems.length}</span></h2>
|
||||||
|
{#each problems as server (server.name)}
|
||||||
|
{@const status = serverHealth(server, observationNow())}
|
||||||
|
{@const operation = attentionOperations.find(item => item.server === server.name)}
|
||||||
|
<div class="attention-row">
|
||||||
|
<div class="attention-identity"><a class="server-link" href={contextHref(`/servers/${encodeURIComponent(server.name)}`, $page.url)}><ServerIdentity name={server.name} mapLabel={fleetIdentity(server).map} /></a><button class="attention-pin" aria-pressed={pinned.includes(server.name)} aria-label={`${pinned.includes(server.name) ? 'Unpin' : 'Pin'} ${serverTitle(server.name)}`} onclick={() => togglePin(server.name)}>{pinned.includes(server.name) ? '★' : '☆'}</button></div>
|
||||||
|
<StatusBadge state={operation && status.state === 'online' ? 'attention' : status.state} />
|
||||||
|
<p>{operation && status.state === 'online' ? operation.label : status.reason}{#if status.since}<span title={status.since}> · Evidence {elapsedSince(status.since, $uiNow)} ago</span>{/if}</p>
|
||||||
|
<Button size="sm" href={contextHref(operation && status.state === 'online' ? operation.href : status.actionHref, $page.url)}>{operation && status.state === 'online' ? 'Review operation' : status.actionLabel}</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
<label class="search"><span class="sr-only">Find a lobby or player</span><input type="search" bind:value={query} placeholder="Search lobbies, maps, or players…" /></label>
|
<label class="search"><span class="sr-only">Search servers, maps, or players</span><input type="search" bind:value={query} placeholder="Search fleet…" /></label>
|
||||||
<button class="view-options-toggle" aria-expanded={viewOptionsOpen} aria-controls="fleet-view-options" onclick={() => viewOptionsOpen = !viewOptionsOpen}>View options{game ? ' · filtered' : ''}</button>
|
<button class="control" aria-expanded={viewOptionsOpen} aria-controls="fleet-view-options" onclick={() => viewOptionsOpen = !viewOptionsOpen}>View options{game ? ' · filtered' : ''}</button>
|
||||||
</div>
|
</div>
|
||||||
{#if viewOptionsOpen}<div id="fleet-view-options" class="view-options">
|
{#if viewOptionsOpen}<div id="fleet-view-options" class="view-options">
|
||||||
<label>Game<select bind:value={game}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
<label>Game<select bind:value={game}><option value="">All games</option><option value="t5">Black Ops I</option><option value="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>Group by<select bind:value={grouping}><option value="none">None</option><option value="map">Map</option><option value="game">Game</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>
|
<label>Sort by<select bind:value={sort}><option value="duration">Longest match</option><option value="players">Most players</option><option value="round">Highest round</option><option value="name">Lobby name</option></select></label>
|
||||||
<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 class="view-toggle" role="group" aria-label="Display mode"><button class="control" class:selected={view === 'cards'} aria-pressed={view === 'cards'} onclick={() => view = 'cards'}>Cards</button><button class="control" class:selected={view === 'table'} aria-pressed={view === 'table'} onclick={() => view = 'table'}>Table</button></div>
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<div class="chips" role="radiogroup" aria-label="Filter servers">{#each filters as item}
|
<div class="chips" role="radiogroup" aria-label="Filter servers">{#each filters as item}
|
||||||
|
{@const count = servers.filter(server => matchesView(server, '', item.id)).length}
|
||||||
<label class="filter-chip" class:selected={filter === item.id}>
|
<label class="filter-chip" class:selected={filter === item.id}>
|
||||||
<input type="radio" name="fleet-filter" value={item.id} bind:group={filter} aria-label={`${item.label}, ${servers.filter(server => matchesFleet(server, '', item.id, game, pinned, activeOperations, $uiNow)).length} servers`} />
|
<input type="radio" name="fleet-filter" value={item.id} bind:group={filter} aria-label={`${item.label}, ${count} servers`} />
|
||||||
{item.label}<span> {servers.filter(server => matchesFleet(server, '', item.id, game, pinned, activeOperations, $uiNow)).length}</span>
|
{item.label}<span> {count}</span>
|
||||||
</label>
|
</label>
|
||||||
{/each}</div>
|
{/each}</div>
|
||||||
|
{#if query || filter !== 'all' || game}<button class="text-button" onclick={resetFilters}>Clear filters</button>{/if}
|
||||||
</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}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{#if filtered.length === 0}<EmptyState title="No matching servers" description="Try another player or map, or clear your filters.">
|
||||||
{#if filtered.length === 0}<EmptyState title="No matching lobbies" description="Try a different player or map, or clear your filters." />{/if}
|
{#snippet actions()}<Button onclick={resetFilters}>Clear filters</Button>{/snippet}
|
||||||
{#each groups as [key, group] (key)}
|
</EmptyState>{/if}
|
||||||
<section class="map-group" aria-label={group.title}>
|
|
||||||
{#if grouping !== 'none'}<div class="group-heading"><div>{#if grouping === 'map'}<span class="eyebrow">{group.game.toUpperCase()}</span>{/if}<h2>{group.title}</h2><p>{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</p></div>
|
|
||||||
{#if canOperate && group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
|
|
||||||
</div>{/if}
|
|
||||||
{#if view === 'cards'}
|
{#if view === 'cards'}
|
||||||
<div class="cards">{#each group.servers as server (server.name)}
|
{#each sections as section (section.title)}
|
||||||
<article class="server-tile" class:pinned={pinned.includes(server.name)}>
|
<section class="fleet-section" aria-label={section.title}>
|
||||||
|
<div class="section-heading"><h2>{section.title} <span class="count">{section.servers.length}</span></h2></div>
|
||||||
|
{#each grouped(section.servers) as [key, group] (key)}
|
||||||
|
{#if group.title}<div class="group-heading"><h3>{group.title}</h3>{#if canOperate && group.launch}<Button size="sm" variant="ghost" onclick={() => launch(group.launch)}>Add lobby</Button>{/if}</div>{/if}
|
||||||
|
<div class="cards" class:compact={section.compact}>
|
||||||
|
{#each group.servers as server (server.name)}
|
||||||
|
<div class="server-tile">
|
||||||
<ServerCard name={server.name} telemetry={server.telemetry} players={server.last_players} map={server.last_map ?? server.map}
|
<ServerCard name={server.name} telemetry={server.telemetry} players={server.last_players} map={server.last_map ?? server.map}
|
||||||
gametype={server.last_gametype} roster={server.roster} health={server.health}
|
gametype={server.last_gametype} roster={server.roster} health={server.health}
|
||||||
lastSeenAt={server.last_seen_at} lastError={server.last_error} held={server.held}
|
lastSeenAt={server.last_seen_at} lastError={server.last_error} held={server.held}
|
||||||
activeSince={server.active_since} inactiveSince={server.inactive_since}
|
activeSince={server.active_since} inactiveSince={server.inactive_since}
|
||||||
gameStartedAt={server.game_started_at} gameEndedAt={server.game_ended_at} compactEmpty
|
gameStartedAt={server.game_started_at} gameEndedAt={server.game_ended_at} compactEmpty
|
||||||
|
round={currentRound(server, $telemetryByServer.get(server.name), observationNow())}
|
||||||
|
pinned={pinned.includes(server.name)} onPin={() => togglePin(server.name)} longestMatch={spotlight?.name === server.name}
|
||||||
mapLabelOverride={fleetIdentity(server).map} />
|
mapLabelOverride={fleetIdentity(server).map} />
|
||||||
{@render serverOperations(server)}
|
{@render serverOperations(server)}
|
||||||
<div class="tile-actions">{@render serverActions(server)}</div>
|
</div>
|
||||||
</article>
|
{/each}
|
||||||
{/each}</div>
|
</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={contextHref(`/servers/${encodeURIComponent(server.name)}`, $page.url)}><ServerIdentity name={server.name} mapLabel={fleetIdentity(server).map} /></a></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}
|
{/each}
|
||||||
</tbody></table></div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
|
{:else if filtered.length}
|
||||||
|
<div class="table-scroll"><table><caption class="sr-only">Fleet servers</caption>
|
||||||
|
<thead><tr><th scope="col">Server</th><th scope="col">Status</th><th scope="col">Players</th><th scope="col">Round</th><th scope="col">Match time</th><th scope="col">Operation</th><th scope="col">Pin</th></tr></thead>
|
||||||
|
{#each groups as [key, group] (key)}<tbody>
|
||||||
|
{#if group.title}<tr class="table-group"><th colspan="7" scope="rowgroup">{group.title} · {group.servers.length} {group.servers.length === 1 ? 'server' : 'servers'}</th></tr>{/if}
|
||||||
|
{#each group.servers as server (server.name)}
|
||||||
|
{@const round = currentRound(server, $telemetryByServer.get(server.name), observationNow())}
|
||||||
|
<tr><td><a class="server-link" href={contextHref(`/servers/${encodeURIComponent(server.name)}`, $page.url)}><ServerIdentity name={server.name} mapLabel={fleetIdentity(server).map} /></a></td>
|
||||||
|
<td><StatusBadge state={serverHealth(server, observationNow()).state} /></td><td>{freshServer(server, observationNow()) ? server.last_players ?? '—' : '—'}</td>
|
||||||
|
<td>{round ?? '—'}</td><td>{matchDuration(server) >= 0 ? elapsedSince(server.game_started_at, $uiNow) : '—'}</td>
|
||||||
|
<td>{@render serverOperations(server)}</td><td><button class="control pin" aria-pressed={pinned.includes(server.name)} aria-label={`Pin ${serverTitle(server.name)}`} onclick={() => togglePin(server.name)}>{pinned.includes(server.name) ? '★' : '☆'}</button></td></tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>{/each}
|
||||||
|
</table></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<footer class="fleet-footer"><span>{filtered.length} of {servers.length} servers</span><a href="/maintenance?view=versions">Plutonium versions →</a></footer>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#snippet serverOperations(server: FleetServer, showEmpty = false)}
|
{#snippet serverOperations(server: FleetServer)}
|
||||||
{@const operations = activeOperations.filter(op => op.server === server.name)}
|
{@const operations = activeOperations.filter(op => op.server === server.name)}
|
||||||
{#if operations.length}<div class="server-operations">{#each operations as operation (operation.key)}
|
{#if operations.length}<div class="server-operations">{#each operations as operation (operation.key)}
|
||||||
<a href={contextHref(operation.href, $page.url)} class:issue={operation.state === 'attention'} title={operation.detail}><span>{operation.label} →</span><small>{operation.detail}</small></a>
|
<a href={contextHref(operation.href, $page.url)} class:issue={operation.state === 'attention'}>{operation.label} →</a>
|
||||||
{/each}</div>{:else if showEmpty}<span class="hint">No pending work</span>{/if}
|
{/each}</div>{/if}
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet serverActions(server: FleetServer)}
|
|
||||||
<RuntimeVersion name={server.name} version={server.plutonium} after={server.telemetry_after} ready={server.health === 'online'} />
|
|
||||||
<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>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.freshness { margin-left: auto; font-size: var(--text-xs); color: var(--text-tertiary); }
|
.page { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-4); min-width: 0; }
|
||||||
.setup-note { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); }
|
.page-heading, .overview, .section-heading, .group-heading, .fleet-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
|
||||||
.setup-note strong { color: var(--text-primary); }
|
.overview { justify-content: flex-start; gap: var(--space-2) var(--space-5); color: var(--text-secondary); font-size: var(--text-sm); }
|
||||||
@media (max-width: 600px) { .freshness { flex-basis: 100%; margin-left: 0; } .no-campaigns { display: none; } }
|
.overview strong { color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||||
.page { display: flex; flex-direction: column; gap: var(--space-4); }
|
.freshness { margin-left: auto; color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||||
button, input, select { font: inherit; }
|
button, input, select { font: inherit; }
|
||||||
button { cursor: pointer; }
|
button { cursor: pointer; }
|
||||||
button:focus-visible, a:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
|
.metric-link, .text-button { color: var(--accent-primary-hover); background: none; border: 0; padding: 0; text-decoration: underline; text-underline-offset: 3px; }
|
||||||
.overview { display: flex; align-items: center; flex-wrap: wrap; padding: 12px 16px; gap: 8px 24px; background: var(--surface); border-radius: var(--radius-md); border: 1px solid var(--border-subtle); color: var(--text-secondary); font-size: var(--text-sm); }
|
.setup-note, .error { display: flex; gap: var(--space-3); flex-wrap: wrap; padding: var(--space-3); border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||||
.overview strong { color: var(--text-primary); font-variant-numeric: tabular-nums; margin-right: 4px; }
|
.error { border-color: var(--state-warning); }
|
||||||
.view-options { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; padding: 16px; background: var(--surface); border-radius: var(--radius-md); }
|
.error button { color: var(--accent-primary-hover); background: none; border: 0; text-decoration: underline; }
|
||||||
.view-options label { flex: 1 1 140px; }
|
.toolbar, .search-row, .filter-row, .chips { min-width: 0; max-width: 100%; }
|
||||||
.view-options-toggle { background: var(--surface-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-primary); padding: 8px 12px; font-size: var(--text-sm); }
|
.toolbar { display: grid; gap: var(--space-3); }
|
||||||
.overview > * { min-width: 0; }
|
.search-row, .filter-row { display: flex; gap: var(--space-3); align-items: center; }
|
||||||
.metric-link { text-align: left; color: inherit; text-decoration: none; background: none; border: 0; padding: 0; }
|
.search { flex: 1; min-width: 0; }
|
||||||
|
.search input { width: 100%; }
|
||||||
.hint { display: block; color: var(--text-secondary); font-size: 12px; margin-top: 6px; }
|
input, select { color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-md); min-height: 40px; padding: var(--space-2) var(--space-3); max-width: 100%; font-size: var(--text-sm); }
|
||||||
.attention { display: flex; gap: 14px; align-items: center; padding: 16px 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
.control, .filter-chip { color: var(--text-secondary); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: var(--space-2) var(--space-3); font-size: var(--text-xs); white-space: nowrap; }
|
||||||
.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); }
|
.control { min-height: 40px; }
|
||||||
.has-issues { border-color: color-mix(in srgb, var(--state-warning) 35%, transparent); }
|
.view-options { display: flex; align-items: end; flex-wrap: wrap; gap: var(--space-3); padding: var(--space-3); background: var(--surface); border-radius: var(--radius-md); }
|
||||||
.has-issues .attention-mark { color: var(--state-warning); }
|
.view-options label { display: grid; gap: var(--space-2); flex: 1 1 130px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
.attention-copy { flex: 1; display: flex; flex-direction: column; gap: 5px; }
|
.chips, .view-toggle { display: flex; gap: var(--space-2); }
|
||||||
|
.chips { flex-wrap: wrap; }
|
||||||
.attention button, .result-count button, .error button { background: none; color: var(--accent-primary-hover); border: 0; padding: 0; text-align: left; }
|
.filter-chip { position: relative; display: flex; align-items: center; gap: var(--space-2); cursor: pointer; }
|
||||||
.spotlight { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; padding: 12px 16px; background: linear-gradient(110deg, color-mix(in srgb, var(--accent-primary) 15%, var(--surface)), var(--surface)); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); color: var(--text-primary); text-decoration: none; }
|
|
||||||
.spotlight > div { flex: 1; min-width: 190px; }
|
|
||||||
.spotlight strong { display: block; margin-top: 6px; }
|
|
||||||
.spotlight > span:last-child { color: var(--text-secondary); font-size: 13px; }
|
|
||||||
.round { font-family: var(--font-mono); font-size: 23px; color: var(--state-online); }
|
|
||||||
.eyebrow { color: var(--text-secondary); text-transform: uppercase; font-size: var(--text-xs); letter-spacing: .12em; font-weight: 600; }
|
|
||||||
.toolbar { display: flex; flex-direction: column; gap: var(--space-3); }
|
|
||||||
.search-row { display: flex; gap: 12px; flex-wrap: wrap; }
|
|
||||||
label { display: flex; flex-direction: column; gap: 8px; color: var(--text-secondary); font-size: 12px; }
|
|
||||||
.search { flex: 1; min-width: min(280px, 100%); }
|
|
||||||
input, select { color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-md); min-height: 42px; padding: 10px 12px; max-width: 100%; }
|
|
||||||
input::placeholder { color: var(--text-secondary); }
|
|
||||||
.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; }
|
|
||||||
.filter-chip, .view-toggle button, .pin { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); font-size: var(--text-xs); }
|
|
||||||
.filter-chip { position: relative; display: flex; flex-direction: row; align-items: center; cursor: pointer; }
|
|
||||||
.filter-chip input { position: absolute; width: 1px; height: 1px; min-height: 0; padding: 0; opacity: 0; }
|
.filter-chip input { position: absolute; width: 1px; height: 1px; min-height: 0; padding: 0; opacity: 0; }
|
||||||
.filter-chip:focus-within { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
|
.filter-chip:focus-within { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
|
||||||
.filter-chip span { opacity: .75; font-variant-numeric: tabular-nums; }
|
.selected { color: var(--accent-primary-hover); border-color: var(--accent-primary); background: color-mix(in srgb, var(--accent-primary) 12%, var(--surface)); }
|
||||||
button.selected, .filter-chip.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); }
|
.filter-chip span, .count { color: var(--text-secondary); font-variant-numeric: tabular-nums; font-weight: var(--weight-regular); }
|
||||||
.result-count { justify-content: flex-start; font-size: 12px; color: var(--text-secondary); }
|
h2, h3 { margin: 0; font-size: var(--text-md); font-weight: var(--weight-semibold); }
|
||||||
|
h2 .count { margin-left: var(--space-2); font-size: var(--text-sm); }
|
||||||
.map-group { display: flex; flex-direction: column; gap: 16px; }
|
h3 { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||||
.group-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
.attention { border: 1px solid color-mix(in srgb, var(--state-warning) 45%, var(--border-subtle)); border-radius: var(--radius-lg); overflow: hidden; background: var(--surface); }
|
||||||
h2 { margin: 4px 0 6px; font-size: 20px; letter-spacing: -.03em; }
|
.attention h2 { padding: var(--space-3) var(--space-4); font-size: var(--text-sm); color: var(--state-warning); }
|
||||||
.group-heading p { margin: 0; color: var(--text-secondary); font-size: 12px; }
|
.attention-row { display: grid; grid-template-columns: minmax(150px, 1fr) auto minmax(180px, 1.5fr) auto; gap: var(--space-3); align-items: center; padding: var(--space-3) var(--space-4); border-top: 1px solid var(--border-subtle); }
|
||||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(310px, 100%), 1fr)); gap: 16px; align-items: start; }
|
.attention-identity { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
|
||||||
.server-tile { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); overflow: hidden; background: var(--surface); }
|
.attention-pin { color: var(--text-secondary); background: none; border: 0; padding: var(--space-2); font-size: var(--text-md); }
|
||||||
.server-tile.pinned { border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
|
.attention-row p { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||||
.server-tile :global(.card) { border-radius: 0; }
|
.attention-row p span { white-space: nowrap; }
|
||||||
.tile-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border-subtle); }
|
.server-link { color: var(--text-primary); text-decoration: none; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
|
||||||
.pin { white-space: nowrap; }
|
.server-link:hover { text-decoration: underline; }
|
||||||
.server-operations { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; font-size: 12px; }
|
.fleet-section { display: grid; gap: var(--space-3); min-width: 0; }
|
||||||
.server-operations a { text-decoration: none; color: var(--accent-primary-hover); overflow-wrap: anywhere; }
|
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(285px, 100%), 1fr)); gap: var(--space-3); align-items: start; }
|
||||||
.server-operations small { display: block; margin-top: 5px; color: var(--text-secondary); }
|
.cards.compact { grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); }
|
||||||
|
.server-tile { min-width: 0; }
|
||||||
|
.server-operations { display: grid; gap: var(--space-1); padding: var(--space-2) var(--space-3); font-size: var(--text-xs); }
|
||||||
|
.server-operations a { text-decoration: none; }
|
||||||
|
.server-operations a:hover { text-decoration: underline; }
|
||||||
.server-operations .issue { color: var(--state-warning); }
|
.server-operations .issue { color: var(--state-warning); }
|
||||||
.error { padding: 16px; border: 1px solid var(--state-warning); border-radius: var(--radius-md); font-size: 13px; line-height: 1.7; }
|
.table-scroll { position: relative; overflow-x: auto; max-width: 100%; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||||
.error button { margin-left: 8px; }
|
table { width: 100%; border-collapse: collapse; text-align: left; background: var(--surface); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
|
||||||
.loading { padding: 70px 20px; text-align: center; color: var(--text-secondary); background: var(--surface); border-radius: var(--radius-lg); }
|
th, td { padding: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
|
||||||
.table-scroll { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
thead th { color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-medium); white-space: nowrap; }
|
||||||
table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: 13px; text-align: left; }
|
.table-group { background: var(--surface-raised); }
|
||||||
th { color: var(--text-secondary); font-weight: 500; white-space: nowrap; font-size: 12px; }
|
.table-group th { font-weight: var(--weight-medium); font-size: var(--text-sm); }
|
||||||
th, td { padding: 14px 16px; border-bottom: 1px solid var(--border-subtle); }
|
td .server-operations { padding: 0; }
|
||||||
tr:last-child td { border-bottom: 0; }
|
.pin { font-size: var(--text-md); }
|
||||||
td small { display: block; color: var(--text-secondary); margin-top: 6px; }
|
.fleet-footer { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||||
td .server-operations { padding: 0; min-width: 180px; }
|
|
||||||
.lobby-link { color: var(--text-primary); text-decoration: none; font-weight: 600; }
|
|
||||||
.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%); }
|
.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: 1000px) { .attention-row { grid-template-columns: 1fr auto; } .attention-row p { grid-column: 1; } }
|
||||||
@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; } .spotlight { padding: 18px; gap: 12px; } .spotlight > div { flex-basis: 100%; } }
|
@media (max-width: 600px) {
|
||||||
|
.page { gap: var(--space-3); }
|
||||||
@media (max-width: 600px) { .overview { padding: 10px 12px; gap: 8px 16px; } .search-row { flex-wrap: nowrap; } .search { flex-basis: auto; min-width: 0; } .view-options-toggle { flex-shrink: 0; } .chips { flex-wrap: nowrap; overflow-x: auto; padding: 3px; width: 100%; } .filter-chip { white-space: nowrap; flex-shrink: 0; min-height: 44px; } .attention { padding: 10px 12px; font-size: 13px; } .spotlight { padding:12px; gap:8px; } .spotlight > div { min-width:0; } .page { gap: 16px; } .group-heading p { display: none; } }
|
.overview { gap: var(--space-1) var(--space-3); font-size: var(--text-xs); }
|
||||||
|
.overview .metric-link { display: none; }
|
||||||
|
.freshness { margin-left: 0; }
|
||||||
|
.filter-row { flex-wrap: wrap; }
|
||||||
|
.chips { flex-wrap: nowrap; overflow-x: auto; width: 100%; padding: 3px; }
|
||||||
|
.filter-chip { flex-shrink: 0; min-height: 44px; }
|
||||||
|
.attention-row { gap: var(--space-2); padding: var(--space-3); }
|
||||||
|
.attention-row p { grid-column: 1; }
|
||||||
|
.attention-row :global(.btn) { grid-column: 2; grid-row: 2; justify-self: end; padding-inline: var(--space-2); font-size: var(--text-xs); }
|
||||||
|
.attention h2 { padding: var(--space-3); }
|
||||||
|
.cards { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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<ComponentProps<typeof ServerCard>> = {}) {
|
||||||
|
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(/<dt[^>]*>Players<\/dt><dd[^>]*>4<span[^>]*>\/4<\/span><\/dd>/);
|
||||||
|
expect(body).toMatch(/<dt[^>]*>Round<\/dt><dd[^>]*>31<\/dd>/);
|
||||||
|
expect(body).toMatch(/<dt[^>]*>Match time<\/dt><dd[^>]*>1h 40m<\/dd>/);
|
||||||
|
expect(body).toContain('Longest match');
|
||||||
|
expect(body).not.toMatch(/Last seen|Last reported|Zombies Classic|>Active</);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps capacity unknown when unavailable and never invents a four-player limit', () => {
|
||||||
|
const body = html({ players: 2, capacity: null });
|
||||||
|
expect(body).toContain('title="Capacity unavailable"');
|
||||||
|
expect(body).toMatch(/>2<span[^>]*>\/\?<\/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(/<dt[^>]*>Round<\/dt><dd[^>]*>35<\/dd>/);
|
||||||
|
expect(html({ round: 40 })).toMatch(/<dt[^>]*>Round<\/dt><dd[^>]*>40<\/dd>/);
|
||||||
|
expect(html({ round: null })).toMatch(/<dt[^>]*>Round<\/dt><dd[^>]*>—<\/dd>/);
|
||||||
|
expect(html({ gameStartedAt: at(0) })).toMatch(/<dt[^>]*>Round<\/dt><dd[^>]*>—<\/dd>/);
|
||||||
|
telemetryByServer.set(new Map());
|
||||||
|
expect(html({ telemetry: telemetry(31, 100_000) })).toMatch(/<dt[^>]*>Round<\/dt><dd[^>]*>—<\/dd>/);
|
||||||
|
expect(html({ gameEndedAt: at(1000), round: 40 })).toContain('Match ended');
|
||||||
|
expect(html({ gameEndedAt: at(1000), round: 40 })).not.toMatch(/<dd[^>]*>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(/<th[^>]*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(/<article\b/);
|
||||||
|
expect(body.match(/<a\b/g)).toHaveLength(1);
|
||||||
|
expect(body.match(/<a\b[\s\S]*?<\/a>/)?.[0]).not.toContain('<button');
|
||||||
|
expect(body).toContain('aria-pressed="true"');
|
||||||
|
expect(body).toContain('aria-label="Unpin Kino Der Toten · Lobby 1"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses empty healthy lobbies to readiness and empty duration', () => {
|
||||||
|
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('<dl');
|
||||||
|
expect(body).not.toContain('<table');
|
||||||
|
expect(body).not.toContain('Player roster unavailable');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { FleetServer } from '../../src/lib/fleet-overview.js';
|
||||||
|
import { serverHealth } from '../../src/lib/ui/server-health.js';
|
||||||
|
|
||||||
|
const now = Date.parse('2026-10-07T12:00:00Z');
|
||||||
|
const iso = (offset = 0) => 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,12 +47,12 @@ describe('status accessibility', () => {
|
|||||||
roster: [{ num: 0, name: 'Player One', score: 30000, ping: 40, guid: '1' }]
|
roster: [{ num: 0, name: 'Player One', score: 30000, ping: 40, guid: '1' }]
|
||||||
} }).body;
|
} }).body;
|
||||||
const card = body.match(/<a\b[^>]*>/)?.[0] ?? '';
|
const card = body.match(/<a\b[^>]*>/)?.[0] ?? '';
|
||||||
const titleId = card.match(/aria-labelledby="([^"]+)"/)?.[1];
|
const titleId = card.match(/aria-labelledby="([^"]+)"/)?.[1]?.split(' ')[0];
|
||||||
expect(titleId).toBeTruthy();
|
expect(titleId).toBeTruthy();
|
||||||
expect(body).toContain(`id="${titleId}"`);
|
expect(body).toContain(`id="${titleId}"`);
|
||||||
expect(card).not.toContain('aria-label=');
|
expect(card).not.toContain('aria-label=');
|
||||||
expect(body).toContain('Online');
|
expect(body).toContain('Online');
|
||||||
expect(body).toContain('Round 30');
|
expect(body).toMatch(/<dt[^>]*>Round<\/dt><dd[^>]*>30<\/dd>/);
|
||||||
expect(body).toContain('Player One');
|
expect(body).toContain('Player One');
|
||||||
expect(body).not.toMatch(/aria-live=|role="status"/);
|
expect(body).not.toMatch(/aria-live=|role="status"/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user