diff --git a/src/lib/fleet-overview.ts b/src/lib/fleet-overview.ts index 826202d..825e2f9 100644 --- a/src/lib/fleet-overview.ts +++ b/src/lib/fleet-overview.ts @@ -1,4 +1,5 @@ import { parseServerName, zombieMapBySlug } from './map-aliases.js'; +import { freshStatusTimestamp } from './server-freshness.js'; import type { ZombieGame } from './map-aliases.js'; import type { RoundTelemetry } from './telemetry.js'; import type { Health, RosterPlayer } from './ui/stores/servers.js'; @@ -40,8 +41,7 @@ export type FleetFilter = 'all' | 'occupied' | 'empty' | 'attention' | 'testing' export type FleetSort = 'name' | 'players' | 'round' | 'duration'; export function freshServer(server: FleetServer, now: number): boolean { - const age = now - Date.parse(server.last_seen_at ?? ''); - return server.health === 'online' && age >= 0 && age <= 30_000; + return server.health === 'online' && !server.last_error && freshStatusTimestamp(server.last_seen_at, now); } export function fleetIdentity(server: FleetServer) { diff --git a/src/lib/server-freshness.ts b/src/lib/server-freshness.ts new file mode 100644 index 0000000..ec01e72 --- /dev/null +++ b/src/lib/server-freshness.ts @@ -0,0 +1,10 @@ +// Allow a polling cycle plus time for queued RCON requests and delivery. +export const STATUS_REFRESH_MS = 30_000; +export const ONLINE_MAX_MS = STATUS_REFRESH_MS + 15_000; +// The UI clock ticks once per second and can trail a newly arrived report. +const CLOCK_TOLERANCE_MS = 1_000; + +export function freshStatusTimestamp(timestamp: string | null, now: number): boolean { + const age = now - Date.parse(timestamp ?? ''); + return age >= -CLOCK_TOLERANCE_MS && age <= ONLINE_MAX_MS; +} diff --git a/src/lib/server/fleet/health.ts b/src/lib/server/fleet/health.ts index 96726bf..9936822 100644 --- a/src/lib/server/fleet/health.ts +++ b/src/lib/server/fleet/health.ts @@ -1,24 +1,24 @@ +import { freshStatusTimestamp } from '../../server-freshness.js'; export type Health = 'online' | 'stale' | 'offline' | 'starting'; -const ONLINE_MAX_MS = 30_000; const STALE_MAX_MS = 120_000; /** * Classify server health from its last successful RCON timestamp. - * `offline` if we've never seen it, or if RCON just failed, or if the last - * successful reply is >120s old. `stale` if the last reply is 30-120s old - * (RCON glitchy). `online` if fresh. + * `offline` if we've never seen it or the last successful reply is >120s old. + * `stale` on an RCON failure or if the last reply is 45-120s old. + * `online` if fresh, including the allowance for polling and delivery. */ export function classifyHealth(lastSeenAt: string | null, lastError: string | null, containerState?: string | null): Health { if (containerState === 'starting' || containerState === 'restarting') return 'starting'; if (containerState && containerState !== 'running') return 'offline'; if (!lastSeenAt) return 'offline'; const age = Date.now() - new Date(lastSeenAt).getTime(); - if (!Number.isFinite(age) || age < 0) return 'offline'; - if (age <= ONLINE_MAX_MS) { + if (freshStatusTimestamp(lastSeenAt, Date.now())) { // Even with a fresh last_seen, a very recent error demotes to stale return lastError ? 'stale' : 'online'; } + if (!Number.isFinite(age) || age < 0) return 'offline'; if (age <= STALE_MAX_MS) return 'stale'; return 'offline'; } diff --git a/src/lib/server/pipeline/worker.ts b/src/lib/server/pipeline/worker.ts index 3938619..689be49 100644 --- a/src/lib/server/pipeline/worker.ts +++ b/src/lib/server/pipeline/worker.ts @@ -9,10 +9,10 @@ import { listServers, setServerField } from '../fleet/servers.js'; import { komodoInspectHealthy } from '../integrations/komodo.js'; import { restartSettling } from './restart-rollout.js'; import { classifyHealth } from '../fleet/health.js'; +import { STATUS_REFRESH_MS } from '../../server-freshness.js'; import { scanMatchState } from '../fleet/match-log.js'; const TICK_MS = 15_000; -const STATUS_REFRESH_MS = 30_000; // Cap concurrent UDP RCON calls so a 15-server fleet doesn't blast the // host all at once. const STATUS_CONCURRENCY = 4; diff --git a/tests/unit/fleet-overview.test.ts b/tests/unit/fleet-overview.test.ts index 21b8a66..20f3485 100644 --- a/tests/unit/fleet-overview.test.ts +++ b/tests/unit/fleet-overview.test.ts @@ -30,14 +30,14 @@ describe('Fleet discovery and match freshness', () => { expect(fleetIdentity({ ...server, game_mode: 'mp' }).launch).toBeNull(); }); it('does not classify stale, missing, or starting occupancy as an empty lobby', () => { - for (const change of [{ last_seen_at: iso(-31_000) }, { last_players: null }, { health: 'starting' as const }, { health: 'offline' as const }]) { + for (const change of [{ last_seen_at: iso(-46_000) }, { last_players: null }, { health: 'starting' as const }, { health: 'offline' as const }]) { expect(matchesFleet({ ...server, last_players: 0, ...change }, '', 'empty', '', [], [], now)).toBe(false); } expect(matchesFleet({ ...server, last_players: 0 }, '', 'empty', '', [], [], now)).toBe(true); expect(matchesFleet(server, '', 'occupied', 't5', [], [], now)).toBe(false); }); it('includes stale health and failed operations in attention without flagging normal startup', () => { - expect(matchesFleet({ ...server, last_seen_at: iso(-31_000) }, '', 'attention', '', [], [], now)).toBe(true); + expect(matchesFleet({ ...server, last_seen_at: iso(-46_000) }, '', 'attention', '', [], [], now)).toBe(true); expect(matchesFleet(server, '', 'attention', '', [], [{ ...operation, state: 'attention' }], now)).toBe(true); expect(matchesFleet({ ...server, health: 'starting' }, '', 'attention', '', [], [], now)).toBe(false); }); @@ -59,7 +59,7 @@ describe('one-shot lobby watches', () => { expect(watchSatisfied(watch, { ...server, last_players: 0 }, [], now)).toBe(true); expect(watchSatisfied(watch, undefined, [], now)).toBe(false); expect(watchSatisfied(watch, { ...server, last_players: null }, [], now)).toBe(false); - expect(watchSatisfied(watch, { ...server, last_players: 0, last_seen_at: iso(-31_000) }, [], now)).toBe(false); + expect(watchSatisfied(watch, { ...server, last_players: 0, last_seen_at: iso(-46_000) }, [], now)).toBe(false); }); it('only completes the watched operation on explicit success', () => { const watch = { id: 'op', server: server.name, kind: 'operation' as const, operationKey: operation.key, href: '/' }; diff --git a/tests/unit/server-freshness.test.ts b/tests/unit/server-freshness.test.ts new file mode 100644 index 0000000..185b992 --- /dev/null +++ b/tests/unit/server-freshness.test.ts @@ -0,0 +1,37 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { freshStatusTimestamp, STATUS_REFRESH_MS } from '../../src/lib/server-freshness.js'; +import { classifyHealth } from '../../src/lib/server/fleet/health.js'; +import { freshServer, type FleetServer } from '../../src/lib/fleet-overview.js'; + +afterEach(() => vi.useRealTimers()); + +it('keeps healthy reports online across polling and delivery delays, then expires them', () => { + vi.useFakeTimers(); + const reportAt = Date.parse('2026-09-18T12:00:00Z'); + const timestamp = new Date(reportAt).toISOString(); + for (const elapsed of [-999, 0, STATUS_REFRESH_MS, STATUS_REFRESH_MS + 4000, 45_000, 45_001]) { + const now = reportAt + elapsed; + vi.setSystemTime(now); + const expected = elapsed <= 45_000; + const health = classifyHealth(timestamp, null, 'running'); + expect(health).toBe(expected ? 'online' : 'stale'); + expect(freshServer({ health, last_seen_at: timestamp, last_error: null } as FleetServer, now)).toBe(expected); + } + vi.setSystemTime(reportAt + 120_001); + expect(classifyHealth(timestamp, null, 'running')).toBe('offline'); +}); + +it('does not hide failures or accept missing, invalid, or far-future timestamps', () => { + vi.useFakeTimers(); + const now = Date.parse('2026-09-18T12:00:00Z'); + vi.setSystemTime(now); + const timestamp = new Date(now).toISOString(); + expect(classifyHealth(timestamp, 'timeout', 'running')).toBe('stale'); + expect(classifyHealth(timestamp, null, 'stopped')).toBe('offline'); + expect(classifyHealth(timestamp, null, 'restarting')).toBe('starting'); + expect(freshServer({ health: 'online', last_seen_at: timestamp, last_error: 'timeout' } as FleetServer, now)).toBe(false); + for (const invalid of [null, 'invalid', new Date(now + 1001).toISOString()]) { + expect(freshStatusTimestamp(invalid, now)).toBe(false); + expect(classifyHealth(invalid, null, 'running')).toBe('offline'); + } +});