fix: honor Komodo health and prefer fresh telemetry across fleet status

This commit is contained in:
2026-09-27 15:34:30 -04:00
parent 46a89ea4b0
commit 8ea696417f
27 changed files with 488 additions and 315 deletions
@@ -1,6 +1,7 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import type { GameTelemetry } from '$lib/telemetry.js';
import { freshTelemetry } from '$lib/server-observation.js';
import { uiNow } from '$ui/stores/servers.js';
import { elapsedSince } from '$ui/duration.js';
import { mapTitle } from '$ui/server-labels.js';
@@ -9,8 +10,9 @@
import Badge from './primitives/Badge.svelte';
import EmptyState from './primitives/EmptyState.svelte';
let { report, mapLabel, game }: { report: GameTelemetry | undefined; mapLabel?: string; game: string } = $props();
const timingNow = $derived(report && (report.event === 'game_end' || $uiNow - Date.parse(report.receivedAt) > 120_000)
let { report, mapLabel, game, current = true, after = null }: { report: GameTelemetry | undefined; mapLabel?: string; game: string; current?: boolean; after?: string | null } = $props();
const fresh = $derived(current && freshTelemetry(report, $uiNow, after));
const timingNow = $derived(report && (report.event === 'game_end' || !fresh)
? Date.parse(report.receivedAt) : $uiNow);
const columns = [
{ key: 'score', label: 'Score' },
@@ -38,11 +40,11 @@
</script>
{#if !report}
<EmptyState title="Waiting for game data" description="The scoreboard will appear when this server reports a game start, round change, or game end." />
<EmptyState title="Waiting for game data" description="The scoreboard will appear when this server reports a game start, round change, heartbeat, or game end." />
{:else}
<header class="scoreboard-header">
<div class="match">
<span class="eyebrow">{report.event === 'game_end' ? 'Final scoreboard' : 'Latest scoreboard'}</span>
<span class="eyebrow">{report.event === 'game_end' ? 'Final scoreboard' : fresh ? 'Live scoreboard' : 'Last reported scoreboard'}</span>
<div class="match-title">
<h2 title={report.map}>{mapLabel ?? mapTitle(report.map)}</h2>
{#if report.event !== 'game_start'}<Badge variant="accent" size="md">Round {report.round}</Badge>{/if}
@@ -51,7 +53,8 @@
</div>
</div>
<div class="report-meta">
<span>{players.length} {players.length === 1 ? 'player' : 'players'}</span>
<span>{players.length} {players.length === 1 ? 'player' : 'players'} in this report</span>
{#if !fresh}<Badge variant="warning">Historical report</Badge>{/if}
<time datetime={report.receivedAt} title={$timeFormat.dateTime(report.receivedAt)}>
Updated {elapsedSince(report.receivedAt, $uiNow)} ago
</time>
+6 -5
View File
@@ -1,18 +1,19 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { elapsedSince } from '$ui/duration.js';
let { name, since = null }: { name: string; since?: string | null } = $props();
let report = $derived($telemetryByServer.get(name));
let fresh = $derived(report && $uiNow - Date.parse(report.receivedAt) >= 0
&& $uiNow - Date.parse(report.receivedAt) <= 120_000
import type { GameTelemetry } from '$lib/telemetry.js';
let { name, since = null, report: observation }: { name: string; since?: string | null; report?: GameTelemetry } = $props();
let report = $derived(observation ?? $telemetryByServer.get(name));
let fresh = $derived(report && freshStatusTimestamp(report.receivedAt, $uiNow)
&& (!since || Date.parse(report.receivedAt) >= Date.parse(since)));
</script>
{#if report && (report.event === 'round_change' || report.event === 'heartbeat') && fresh}
<div class="round" aria-live="polite">
<strong>Round {report.round}</strong>
<span title={$timeFormat.dateTime(report.receivedAt)}>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago · {report.players.length} players</span>
<span title={$timeFormat.dateTime(report.receivedAt)}>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago</span>
</div>
{/if}
+6 -4
View File
@@ -16,6 +16,7 @@
betaId: number;
}
interface Props {
telemetry?: import('$lib/telemetry.js').GameTelemetry;
name: string;
players?: number | null;
map?: string | null;
@@ -35,6 +36,7 @@
mapLabelOverride?: string;
}
let {
telemetry,
name,
players = null,
map = null,
@@ -66,7 +68,7 @@
let topRoster = $derived(
(roster ?? [])
.slice()
.sort((a, b) => b.score - a.score)
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
.slice(0, 4)
);
let showRoster = $derived(health === 'online' || health === 'stale');
@@ -134,7 +136,7 @@
{#if topRoster.length}<div class="roster-heading">Last reported players</div>{/if}
{/if}
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} since={gameStartedAt} />{/if}
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} report={telemetry} since={gameStartedAt} />{/if}
{#if showTiming && !(compactEmpty && players === 0)}
<div class="timing">
@@ -169,8 +171,8 @@
{#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}ms</span>
<span class="pscore">{p.score ?? '—'}</span>
<span class="pping">{p.ping === null ? '—' : `${p.ping}ms`}</span>
</li>
{/each}
</ul>
+12 -5
View File
@@ -1,6 +1,7 @@
import { selectServerObservation } from '$lib/server-observation.js';
import { derived, writable } from 'svelte/store';
import { browser } from '$app/environment';
import { statusByServer, statusReceivedAt, syncServerTime, matchEventsByServer } from './servers.js';
import { statusByServer, statusReceivedAt, syncServerTime, matchEventsByServer, telemetryByServer, uiNow } from './servers.js';
import { type FleetServer, type FleetOperation } from '$lib/fleet-overview.js';
export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperation[]; receivedAt: number }>({
@@ -10,18 +11,24 @@ export const fleetError = writable('');
export const fleetRefreshing = writable(false);
export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer], ([snapshot, live, received, matches]) =>
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer, telemetryByServer, uiNow], ([snapshot, live, received, matches, reports, now]) =>
snapshot.servers.map(server => {
const status = received > snapshot.receivedAt ? live.get(server.name) : undefined;
const current = status ? { ...server, last_players: status.players, last_map: status.map,
const base = status ? { ...server, last_players: status.players, last_map: status.map,
last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError,
roster: status.roster, health: status.health, endpoint: status.endpoint, replySnippet: status.replySnippet, active_since: status.activeSince,
inactive_since: status.inactiveSince, game_started_at: status.gameStartedAt,
game_ended_at: status.gameEndedAt } : server;
game_ended_at: status.gameEndedAt, container_state: status.containerState ?? server.container_state,
container_checked_at: status.containerCheckedAt ?? server.container_checked_at,
telemetry_after: status.telemetryAfter ?? server.telemetry_after, rcon_status: status.rconStatus,
telemetry: status.telemetry ?? server.telemetry } : server;
const current = selectServerObservation(base, reports.get(server.name), now);
const match = matches.get(server.name);
// Apply lifecycle events immediately, until a newer status observes them
// or confirms that the lobby has emptied.
if (!match || Date.parse(match.receivedAt) <= Date.parse(current.last_seen_at ?? '')
if (!match || current.health !== 'online'
|| (current.telemetry_after && Date.parse(match.receivedAt) <= Date.parse(current.telemetry_after))
|| Date.parse(match.receivedAt) <= Date.parse(current.last_seen_at ?? '')
|| match.receivedAt < (current.game_started_at ?? '')
|| match.receivedAt < (current.game_ended_at ?? '')) return current;
return { ...current, game_started_at: match.event === 'game_start' ? match.receivedAt : current.game_started_at,
+7 -7
View File
@@ -5,13 +5,8 @@ import type { GameTelemetry } from '$lib/telemetry.js';
export const telemetryByServer = writable<Map<string, GameTelemetry>>(new Map());
export const matchEventsByServer = writable<Map<string, GameTelemetry>>(new Map());
export interface RosterPlayer {
num: number | null;
score: number;
ping: number;
guid: string | null;
name: string;
}
export type { StatusPlayer as RosterPlayer } from '$lib/server-observation.js';
import type { StatusPlayer as RosterPlayer, GameObservation } from '$lib/server-observation.js';
export type Health = 'online' | 'stale' | 'offline' | 'starting' | 'provisioning';
export type RconFailure = 'no-config' | 'no-port' | 'send-error' | 'timeout' | 'bad-reply' | 'no-players';
@@ -23,6 +18,11 @@ export interface RconEndpoint {
export interface ServerStatus {
server: string;
containerState?: string | null;
containerCheckedAt?: string | null;
telemetryAfter?: string | null;
rconStatus?: GameObservation;
telemetry?: GameTelemetry;
players: number | null;
map: string | null;
gametype: string | null;