Show resetting state after game end
This commit is contained in:
@@ -5,7 +5,7 @@ import { emit } from './bus.js';
|
||||
import type { ServerStatusEntry } from './bus.js';
|
||||
import { rconStatusResult } from '../integrations/rcon.js';
|
||||
import type { RconFailure } from '../integrations/rcon.js';
|
||||
import { listServers, setServerField } from '../fleet/servers.js';
|
||||
import { getServer, listServers, setServerField } from '../fleet/servers.js';
|
||||
import { komodoInspectHealthy } from '../integrations/komodo.js';
|
||||
import { restartSettling } from './restart-rollout.js';
|
||||
import { classifyHealth } from '../fleet/health.js';
|
||||
@@ -113,8 +113,14 @@ async function refreshStatus(): Promise<void> {
|
||||
const nextPlayers = result.status.players;
|
||||
let activeSince = srv.active_since;
|
||||
let inactiveSince = srv.inactive_since;
|
||||
const gameStartedAt = srv.game_started_at;
|
||||
const gameEndedAt = srv.game_ended_at;
|
||||
// Telemetry can arrive while the RCON request is in flight.
|
||||
const current = getServer(srv.name) ?? srv;
|
||||
const gameStartedAt = nextPlayers === 0 ? null : current.game_started_at;
|
||||
const gameEndedAt = nextPlayers === 0 ? null : current.game_ended_at;
|
||||
if (nextPlayers === 0) {
|
||||
setServerField(srv.name, 'game_started_at', null);
|
||||
setServerField(srv.name, 'game_ended_at', null);
|
||||
}
|
||||
if (nextPlayers > 0 && (activeSince === null || prevPlayers === 0 || prevPlayers === null)) {
|
||||
activeSince = now;
|
||||
inactiveSince = null;
|
||||
|
||||
@@ -76,11 +76,13 @@
|
||||
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' && (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
|
||||
health === 'online' && (resetting || activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
|
||||
);
|
||||
|
||||
// Shared-element transition to /servers/{name} — Chromium only, others
|
||||
@@ -134,7 +136,12 @@
|
||||
|
||||
{#if showTiming && !(compactEmpty && players === 0)}
|
||||
<div class="timing">
|
||||
{#if activeElapsed}
|
||||
{#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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import { statusByServer, statusReceivedAt, serverTimeNow, syncServerTime } from './servers.js';
|
||||
import { statusByServer, statusReceivedAt, serverTimeNow, syncServerTime, matchEventsByServer } from './servers.js';
|
||||
import { watchSatisfied, type FleetServer, type FleetOperation, type LobbyWatch } from '$lib/fleet-overview.js';
|
||||
import { serverTitle } from '../server-labels.js';
|
||||
import { toast } from '../toast.js';
|
||||
@@ -13,14 +13,22 @@ export const fleetRefreshing = writable(false);
|
||||
export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
|
||||
export const lobbyWatches = writable<LobbyWatch[]>([]);
|
||||
|
||||
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt], ([snapshot, live, received]) =>
|
||||
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer], ([snapshot, live, received, matches]) =>
|
||||
snapshot.servers.map(server => {
|
||||
const status = received > snapshot.receivedAt ? live.get(server.name) : undefined;
|
||||
return status ? { ...server, last_players: status.players, last_map: status.map,
|
||||
const current = 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;
|
||||
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 ?? '')
|
||||
|| 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,
|
||||
game_ended_at: match.event === 'game_end' ? match.receivedAt : null };
|
||||
}));
|
||||
|
||||
let inFlight: Promise<void> | null = null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { browser } from '$app/environment';
|
||||
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;
|
||||
@@ -76,6 +77,13 @@ export function startServerStream(): (() => void) | undefined {
|
||||
source.addEventListener('server-telemetry', (evt) => {
|
||||
const data = JSON.parse((evt as MessageEvent).data) as { entries: GameTelemetry[]; snapshot?: boolean; serverNow?: number };
|
||||
syncServerTime(data.serverNow);
|
||||
matchEventsByServer.update((previous) => {
|
||||
const next = data.snapshot ? new Map<string, GameTelemetry>() : new Map(previous);
|
||||
for (const entry of data.entries) {
|
||||
if (entry.event === 'game_start' || entry.event === 'game_end') next.set(entry.server, entry);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
telemetryByServer.update((previous) => {
|
||||
const next = data.snapshot ? new Map<string, GameTelemetry>() : new Map(previous);
|
||||
for (const entry of data.entries) next.set(entry.server, entry);
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null);
|
||||
let effectiveGameStartedAt = $derived<string | null>(server?.game_started_at ?? null);
|
||||
let effectiveGameEndedAt = $derived<string | null>(server?.game_ended_at ?? null);
|
||||
let resetElapsed = $derived(fresh && effectiveGameEndedAt && server?.last_players !== 0 ? elapsedSince(effectiveGameEndedAt, $uiNow) : null);
|
||||
let activeElapsed = $derived(fresh && effectiveActiveSince ? elapsedSince(effectiveActiveSince, $uiNow) : null);
|
||||
let inactiveElapsed = $derived(fresh && effectiveInactiveSince ? elapsedSince(effectiveInactiveSince, $uiNow) : null);
|
||||
let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince);
|
||||
@@ -260,7 +261,11 @@
|
||||
<div class="sub">{gametypeLabel(effectiveGametype)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if activeElapsed}
|
||||
{#if resetElapsed}
|
||||
<div class="metric-cell">
|
||||
<Stat label="Resetting" value={resetElapsed} size="md" tween={false} />
|
||||
</div>
|
||||
{:else if activeElapsed}
|
||||
<div class="metric-cell">
|
||||
<Stat label="Active for" value={activeElapsed} size="md" tween={false} />
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,30 @@ async function setup() {
|
||||
}
|
||||
|
||||
describe('database fleet and container readiness', () => {
|
||||
it('keeps game_end through occupied polls until game_start or an empty lobby', async () => {
|
||||
const { inspect, rcon, fanoutStatus, getServer } = await setup();
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
const reply = (players: number) => ({ ok: true as const, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
status: { players, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
rcon.mockResolvedValue(reply(1));
|
||||
await postTelemetry(gameEvent('game_start', 0, 1, 'managed'));
|
||||
await postTelemetry(gameEvent('game_end', 10, 1, 'managed'));
|
||||
const ended = getServer('managed')?.game_ended_at;
|
||||
expect(ended).toEqual(expect.any(String));
|
||||
await postTelemetry(gameEvent('heartbeat', 10, 1, 'managed'));
|
||||
await fanoutStatus();
|
||||
expect(getServer('managed')?.game_ended_at).toBe(ended);
|
||||
await postTelemetry(gameEvent('game_start', 0, 1, 'managed'));
|
||||
expect(getServer('managed')?.game_ended_at).toBeNull();
|
||||
await postTelemetry(gameEvent('game_end', 10, 1, 'managed'));
|
||||
rcon.mockResolvedValue(reply(0));
|
||||
await fanoutStatus();
|
||||
expect(getServer('managed')).toMatchObject({ game_started_at: null, game_ended_at: null });
|
||||
rcon.mockResolvedValue(reply(1));
|
||||
await fanoutStatus();
|
||||
expect(getServer('managed')?.game_ended_at).toBeNull();
|
||||
});
|
||||
|
||||
it('waits for game_start telemetry after log initialization and player joins', async () => {
|
||||
const { inspect, rcon, fanoutStatus, getServer } = await setup();
|
||||
const { GET } = await import('../../src/routes/api/servers/+server.js');
|
||||
|
||||
@@ -65,6 +65,27 @@ async function setup() {
|
||||
}
|
||||
|
||||
describe('Fleet report clock', () => {
|
||||
it('applies game lifecycle events immediately and clears them after an empty report', async () => {
|
||||
const { fleetServers } = await setup();
|
||||
const report = (event: string, time: number) => source.dispatchEvent(new MessageEvent('server-telemetry', {
|
||||
data: JSON.stringify({ serverNow: time, entries: [{ server: 't5_zombie_theater_1', event,
|
||||
receivedAt: new Date(time).toISOString(), players: [] }] })
|
||||
}));
|
||||
emit(epoch, [status(epoch, { players: 1 })]);
|
||||
report('game_end', epoch + 1000);
|
||||
expect(get(fleetServers)[0].game_ended_at).toBe(new Date(epoch + 1000).toISOString());
|
||||
report('heartbeat', epoch + 2000);
|
||||
expect(get(fleetServers)[0].game_ended_at).not.toBeNull();
|
||||
report('game_start', epoch + 3000);
|
||||
expect(get(fleetServers)[0].game_ended_at).toBeNull();
|
||||
report('game_end', epoch + 4000);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
emit(epoch + 5000, [status(epoch + 5000)]);
|
||||
expect(get(fleetServers)[0].game_ended_at).toBeNull();
|
||||
emit(epoch + 6000, [status(epoch + 6000, { players: 1 })]);
|
||||
expect(get(fleetServers)[0].game_ended_at).toBeNull();
|
||||
});
|
||||
|
||||
it.each([-2500, 60_000])('keeps fresh HTTP and streamed reports healthy with a browser clock offset of %i ms', async (offset) => {
|
||||
vi.setSystemTime(epoch + offset);
|
||||
const { fleetServers, uiNow, refreshFleet } = await setup();
|
||||
|
||||
Reference in New Issue
Block a user