Rebuild Fleet around attention and compact live matches
This commit is contained in:
@@ -1,23 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import RoundTelemetry from './RoundTelemetry.svelte';
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import { freshStatusTimestamp } from '$lib/server-freshness.js';
|
||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
import { gametypeLabel } from '$ui/gametype.js';
|
||||
import { telemetryByServer, uiNow } from '$ui/stores/servers.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 Card from './primitives/Card.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
import Stat from './primitives/Stat.svelte';
|
||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||
|
||||
interface Held {
|
||||
target: string;
|
||||
betaId: number;
|
||||
}
|
||||
interface Held { target: string; betaId: number; }
|
||||
interface Props {
|
||||
telemetry?: import('$lib/telemetry.js').GameTelemetry;
|
||||
name: string;
|
||||
@@ -37,316 +31,143 @@
|
||||
gameEndedAt?: string | null;
|
||||
compactEmpty?: boolean;
|
||||
mapLabelOverride?: string;
|
||||
round?: number | null;
|
||||
capacity?: number | null;
|
||||
pinned?: boolean;
|
||||
onPin?: () => void;
|
||||
longestMatch?: boolean;
|
||||
}
|
||||
let {
|
||||
telemetry,
|
||||
name,
|
||||
players = null,
|
||||
map = null,
|
||||
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
|
||||
telemetry, name, players = null, map = null, roster = null, health = 'offline',
|
||||
lastSeenAt = null, lastError = null, held = [], restarting = false,
|
||||
inactiveSince = null, gameStartedAt = null, gameEndedAt = null, compactEmpty = true,
|
||||
mapLabelOverride, round, capacity = null, pinned = false, onPin, longestMatch = false
|
||||
}: Props = $props();
|
||||
const id = $props.id();
|
||||
|
||||
function ago(iso: string | null, now: number): string {
|
||||
if (!iso) return 'never';
|
||||
const d = now - new Date(iso).getTime();
|
||||
if (d < 60_000) return `${Math.max(1, Math.floor(d / 1000))}s ago`;
|
||||
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
|
||||
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 number = new Intl.NumberFormat('en-US');
|
||||
const identity = $derived(serverIdentity(name));
|
||||
const title = $derived(mapLabelOverride ?? identity.map);
|
||||
const game = $derived(identity.game === 'Black Ops I' ? 'BO1' : identity.game === 'Black Ops II' ? 'BO2' : identity.game);
|
||||
const compact = $derived(compactEmpty && health === 'online' && players === 0 && !restarting);
|
||||
const state = $derived(restarting ? 'restarting' : compact ? 'ready' : health);
|
||||
const error = $derived(decodeRconError(lastError));
|
||||
let mapLabel = $derived(mapLabelOverride ?? mapTitle(map));
|
||||
const showMap = $derived(mapLabel !== '—' && mapLabel !== serverIdentity(name).map);
|
||||
let gtLabel = $derived(gametypeLabel(gametype));
|
||||
|
||||
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
|
||||
let resetting = $derived(!!gameEndedAt && players !== 0);
|
||||
let resetElapsed = $derived(resetting ? elapsedSince(gameEndedAt, $uiNow) : null);
|
||||
let inactiveElapsed = $derived(inactiveSince ? elapsedSince(inactiveSince, $uiNow) : null);
|
||||
let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt && !!activeSince);
|
||||
let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null);
|
||||
let showTiming = $derived(
|
||||
health === 'online' && (resetting || activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
|
||||
);
|
||||
|
||||
// Shared-element transition to /servers/{name} — Chromium only, others
|
||||
// fall through to a plain nav.
|
||||
let cardStyle = $derived(`view-transition-name: server-${name.replace(/[^a-z0-9_-]/gi, '-')};`);
|
||||
const mapLabel = $derived(mapTitle(map));
|
||||
const showMap = $derived(mapLabel !== '—' && mapLabel !== title);
|
||||
const topRoster = $derived((roster ?? []).slice().sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, 4));
|
||||
const report = $derived.by(() => {
|
||||
const streamed = $telemetryByServer.get(name);
|
||||
return streamed && (!telemetry || Date.parse(streamed.receivedAt) > Date.parse(telemetry.receivedAt)) ? streamed : telemetry;
|
||||
});
|
||||
const reportedRound = $derived(report && (report.event === 'heartbeat' || report.event === 'round_change')
|
||||
&& freshStatusTimestamp(report.receivedAt, $uiNow)
|
||||
&& (!gameStartedAt || Date.parse(report.receivedAt) >= Date.parse(gameStartedAt))
|
||||
? report.round : 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));
|
||||
const knownCapacity = $derived(capacity !== null && Number.isFinite(capacity) && capacity > 0 ? capacity : null);
|
||||
const cardStyle = $derived(`view-transition-name: server-${name.replace(/[^a-z0-9_-]/gi, '-')};`);
|
||||
</script>
|
||||
|
||||
<Card
|
||||
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}
|
||||
|
||||
<Card as="article" padding={compact ? 'sm' : 'md'} class={`server-card ${compact ? 'compact' : ''} ${health === 'offline' ? 'offline' : ''}`} ariaLabelledby={`${id}-title`} style={cardStyle}>
|
||||
<div class="head">
|
||||
<div class="name-row">
|
||||
<strong class="name" id={`${id}-title`}><ServerIdentity {name} mapLabel={mapLabelOverride} /></strong>
|
||||
</div>
|
||||
<div class="status">
|
||||
<a class="identity" href={contextHref(`/servers/${encodeURIComponent(name)}`, $page.url)} aria-labelledby={`${id}-title${identity.lobby !== null ? ` ${id}-lobby` : ''}`}>
|
||||
<h3 id={`${id}-title`}>{title}</h3>
|
||||
<span class="identity-meta">
|
||||
{#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} />
|
||||
{#if health === 'online'}
|
||||
<div class="players">
|
||||
<Stat
|
||||
value={players ?? '—'}
|
||||
size="md"
|
||||
tone={players !== null && players > 0 ? 'online' : 'primary'}
|
||||
tween
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#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 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 !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'}
|
||||
<dl class="metrics">
|
||||
<div><dt>Players</dt><dd title={knownCapacity === null ? 'Capacity unavailable' : `${knownCapacity} player capacity`}>{players ?? '—'}<span class="capacity">/{knownCapacity ?? '?'}</span></dd></div>
|
||||
<div><dt>Round</dt><dd>{currentRound ?? '—'}</dd></div>
|
||||
<div><dt>Match time</dt><dd>{gameElapsed ?? '—'}</dd></div>
|
||||
</dl>
|
||||
{#if gameEndedAt && players !== 0}<p class="match-note">Match ended · waiting for the next match</p>{/if}
|
||||
{:else if health === 'stale'}
|
||||
<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}
|
||||
|
||||
{#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 health === 'online' || health === 'stale'}
|
||||
{#if topRoster.length}
|
||||
<div class="roster">
|
||||
<table aria-label={health === 'stale' ? 'Last reported players' : 'Current players'}>
|
||||
<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>
|
||||
<tbody>{#each topRoster as player (player.guid ?? player.num)}<tr>
|
||||
<td title={player.name ?? undefined}>{player.name || '—'}</td>
|
||||
<td>{player.score === null ? '—' : number.format(player.score)}</td>
|
||||
<td>{player.ping === 999 ? 'Connecting' : player.ping === null ? '—' : number.format(player.ping)}</td>
|
||||
</tr>{/each}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if players !== 0}<p class="roster-unavailable">{health === 'stale' ? 'Waiting for a fresh report' : 'Player roster unavailable'}</p>{/if}
|
||||
{/if}
|
||||
|
||||
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} report={telemetry} since={gameStartedAt} />{/if}
|
||||
|
||||
{#if showTiming && !(compactEmpty && players === 0)}
|
||||
<div class="timing">
|
||||
{#if resetting}
|
||||
<div class="metric">
|
||||
<span class="metric-label">Resetting</span>
|
||||
<span class="metric-value">{resetElapsed}</span>
|
||||
</div>
|
||||
{:else if activeElapsed}
|
||||
<div class="metric">
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
{#if showRoster}
|
||||
{#if topRoster.length > 0}
|
||||
<ul class="roster">
|
||||
{#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 health === 'offline'}
|
||||
<p class="problem-note" title={error ? rconFixHint(error.reason) : undefined}>{error?.detail || 'Server status is unavailable. Open diagnostics for details.'}</p>
|
||||
{#if lastSeenAt}<p class="last-report">Last report {elapsedSince(lastSeenAt, $uiNow)} ago</p>{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if held.length > 0}
|
||||
<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>
|
||||
{#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}
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.stale-note { display: grid; gap: 4px; margin-top: 14px; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.stale-note strong { color: var(--text-primary); font-weight: var(--weight-medium); }
|
||||
.roster-heading { margin-top: 14px; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
/* Global tweaks scoped through :global() since Card is a child component. */
|
||||
:global(.card.restarting) {
|
||||
outline: 1px solid rgba(255, 176, 32, 0.28);
|
||||
:global(.card.server-card) { gap: var(--space-3); transition: border-color var(--dur-fast) var(--ease-standard); }
|
||||
:global(.card.server-card:hover) { border-color: var(--border-strong); }
|
||||
:global(.card.server-card.offline) { border-color: color-mix(in srgb, var(--state-danger) 35%, var(--border-subtle)); }
|
||||
.head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-2); }
|
||||
.identity { min-width: 0; color: inherit; text-decoration: none; display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user