Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
+278
-91
@@ -4,85 +4,215 @@ import { selectServerObservation } from '$lib/server-observation.js';
|
||||
import { currentCapacity } from '$lib/server-capacity.js';
|
||||
import { derived, writable, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import { statusByServer, statusReceivedAt, syncServerTime, serverTimeNow, matchEventsByServer, telemetryByServer, uiNow } from './servers.js';
|
||||
import {
|
||||
statusByServer,
|
||||
statusReceivedAt,
|
||||
syncServerTime,
|
||||
serverTimeNow,
|
||||
matchEventsByServer,
|
||||
telemetryByServer,
|
||||
uiNow
|
||||
} from './servers.js';
|
||||
import { type FleetServer, type FleetOperation } from '$lib/fleet-overview.js';
|
||||
|
||||
interface FleetIntegrations {
|
||||
rcon: boolean;
|
||||
native?: boolean;
|
||||
hostsReady?: number;
|
||||
hostsTotal?: number;
|
||||
rconTimeoutMs: number;
|
||||
defaultRconHost: string;
|
||||
}
|
||||
|
||||
// Each server detail view owns a scoped snapshot; it must not replace the shared fleet.
|
||||
export function createFleetStore(serverName?: string) {
|
||||
const fleet = writable<{ servers: FleetServer[]; operations: FleetOperation[]; receivedAt: number; hostAttention?: number }>({
|
||||
servers: [], operations: [], receivedAt: 0
|
||||
const fleet = writable<{
|
||||
servers: FleetServer[];
|
||||
operations: FleetOperation[];
|
||||
receivedAt: number;
|
||||
hostAttention?: number;
|
||||
}>({
|
||||
servers: [],
|
||||
operations: [],
|
||||
receivedAt: 0
|
||||
});
|
||||
const fleetError = writable('');
|
||||
const fleetRefreshing = writable(false);
|
||||
const fleetIntegrations = writable<{ rcon: boolean; native?: boolean; hostsReady?: number; hostsTotal?: number; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
|
||||
const fleetIntegrations = writable<FleetIntegrations | null>(null);
|
||||
|
||||
const observed = new Map<string, { server: FleetServer; status: unknown; match: unknown; report: unknown; phase: string; value: FleetServer }>();
|
||||
const freshnessPhase = derived([fleet, statusByServer, telemetryByServer, uiNow], ([snapshot, live, reports, now]) => {
|
||||
const times = snapshot.servers.flatMap(server => {
|
||||
const status = live.get(server.name);
|
||||
return [status?.capacityObservedAt, server.capacity_observed_at, status?.lastSeenAt, status?.containerCheckedAt, status?.rconStatus?.last_seen_at, status?.telemetry?.receivedAt,
|
||||
server.last_seen_at, server.container_checked_at, server.last_checked_at, server.rcon_status?.last_seen_at,
|
||||
server.telemetry?.receivedAt, reports.get(server.name)?.receivedAt];
|
||||
});
|
||||
return times.map(time => `${freshStatusTimestamp(time ?? null, now)}:${now - Date.parse(time ?? '') >= 0 && now - Date.parse(time ?? '') <= 120_000}`).join(',');
|
||||
});
|
||||
const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer, telemetryByServer, freshnessPhase], ([snapshot, live, received, matches, reports]) => {
|
||||
const now = get(uiNow);
|
||||
const next = snapshot.servers.map(server => {
|
||||
const candidate = live.get(server.name);
|
||||
const status = (candidate?.receivedAt ?? received) > snapshot.receivedAt ? candidate : undefined;
|
||||
const match = matches.get(server.name);
|
||||
const report = reports.get(server.name);
|
||||
const times = [status?.capacityObservedAt, server.capacity_observed_at, status?.lastSeenAt, status?.containerCheckedAt, status?.rconStatus?.last_seen_at,
|
||||
status?.telemetry?.receivedAt, server.last_seen_at, server.container_checked_at, server.last_checked_at,
|
||||
server.rcon_status?.last_seen_at, server.telemetry?.receivedAt, report?.receivedAt];
|
||||
const phase = times.map(time => `${freshStatusTimestamp(time ?? null, now)}:${now - Date.parse(time ?? '') >= 0 && now - Date.parse(time ?? '') <= 120_000}`).join(',');
|
||||
const saved = observed.get(server.name);
|
||||
if (saved && saved.server === server && saved.status === status && saved.match === match && saved.report === report && saved.phase === phase) return saved.value;
|
||||
const remember = (value: FleetServer) => { observed.set(server.name, { server, status, match, report, phase, value }); return value; };
|
||||
const base = status ? { ...server, last_players: status.players, last_map: status.map,
|
||||
capacity: status.capacity ?? null, capacity_observed_at: status.capacityObservedAt ?? null,
|
||||
last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError,
|
||||
roster: status.roster, health: status.health, endpoint: status.endpoint, active_since: status.activeSince,
|
||||
inactive_since: status.inactiveSince, game_started_at: status.gameStartedAt,
|
||||
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);
|
||||
current.capacity = currentCapacity(current, now);
|
||||
if (current.capacity === null) current.capacity_observed_at = null;
|
||||
// Apply lifecycle events immediately, until a newer status observes them
|
||||
// or confirms that the lobby has emptied.
|
||||
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 remember(current);
|
||||
return remember({ ...current, game_started_at: match.event === 'game_start' ? match.receivedAt : current.game_started_at,
|
||||
game_ended_at: match.event === 'game_end' ? match.receivedAt : null });
|
||||
});
|
||||
const names = new Set(snapshot.servers.map(server => server.name));
|
||||
for (const name of observed.keys()) if (!names.has(name)) observed.delete(name);
|
||||
return next;
|
||||
});
|
||||
const observed = new Map<
|
||||
string,
|
||||
{
|
||||
server: FleetServer;
|
||||
status: unknown;
|
||||
match: unknown;
|
||||
report: unknown;
|
||||
phase: string;
|
||||
value: FleetServer;
|
||||
}
|
||||
>();
|
||||
const freshnessPhase = derived(
|
||||
[fleet, statusByServer, telemetryByServer, uiNow],
|
||||
([snapshot, live, reports, now]) => {
|
||||
const times = snapshot.servers.flatMap((server) => {
|
||||
const status = live.get(server.name);
|
||||
return [
|
||||
status?.capacityObservedAt,
|
||||
server.capacity_observed_at,
|
||||
status?.lastSeenAt,
|
||||
status?.containerCheckedAt,
|
||||
status?.rconStatus?.last_seen_at,
|
||||
status?.telemetry?.receivedAt,
|
||||
server.last_seen_at,
|
||||
server.container_checked_at,
|
||||
server.last_checked_at,
|
||||
server.rcon_status?.last_seen_at,
|
||||
server.telemetry?.receivedAt,
|
||||
reports.get(server.name)?.receivedAt
|
||||
];
|
||||
});
|
||||
return times
|
||||
.map(
|
||||
(time) =>
|
||||
`${freshStatusTimestamp(time ?? null, now)}:${now - Date.parse(time ?? '') >= 0 && now - Date.parse(time ?? '') <= 120_000}`
|
||||
)
|
||||
.join(',');
|
||||
}
|
||||
);
|
||||
const fleetServers = derived(
|
||||
[
|
||||
fleet,
|
||||
statusByServer,
|
||||
statusReceivedAt,
|
||||
matchEventsByServer,
|
||||
telemetryByServer,
|
||||
freshnessPhase
|
||||
],
|
||||
([snapshot, live, received, matches, reports]) => {
|
||||
const now = get(uiNow);
|
||||
const next = snapshot.servers.map((server) => {
|
||||
const candidate = live.get(server.name);
|
||||
const status =
|
||||
(candidate?.receivedAt ?? received) > snapshot.receivedAt
|
||||
? candidate
|
||||
: undefined;
|
||||
const match = matches.get(server.name);
|
||||
const report = reports.get(server.name);
|
||||
const times = [
|
||||
status?.capacityObservedAt,
|
||||
server.capacity_observed_at,
|
||||
status?.lastSeenAt,
|
||||
status?.containerCheckedAt,
|
||||
status?.rconStatus?.last_seen_at,
|
||||
status?.telemetry?.receivedAt,
|
||||
server.last_seen_at,
|
||||
server.container_checked_at,
|
||||
server.last_checked_at,
|
||||
server.rcon_status?.last_seen_at,
|
||||
server.telemetry?.receivedAt,
|
||||
report?.receivedAt
|
||||
];
|
||||
const phase = times
|
||||
.map(
|
||||
(time) =>
|
||||
`${freshStatusTimestamp(time ?? null, now)}:${now - Date.parse(time ?? '') >= 0 && now - Date.parse(time ?? '') <= 120_000}`
|
||||
)
|
||||
.join(',');
|
||||
const saved = observed.get(server.name);
|
||||
if (
|
||||
saved &&
|
||||
saved.server === server &&
|
||||
saved.status === status &&
|
||||
saved.match === match &&
|
||||
saved.report === report &&
|
||||
saved.phase === phase
|
||||
)
|
||||
return saved.value;
|
||||
const remember = (value: FleetServer) => {
|
||||
observed.set(server.name, { server, status, match, report, phase, value });
|
||||
return value;
|
||||
};
|
||||
const base = status
|
||||
? {
|
||||
...server,
|
||||
last_players: status.players,
|
||||
last_map: status.map,
|
||||
capacity: status.capacity ?? null,
|
||||
capacity_observed_at: status.capacityObservedAt ?? null,
|
||||
last_gametype: status.gametype,
|
||||
last_seen_at: status.lastSeenAt,
|
||||
last_error: status.lastError,
|
||||
roster: status.roster,
|
||||
health: status.health,
|
||||
endpoint: status.endpoint,
|
||||
active_since: status.activeSince,
|
||||
inactive_since: status.inactiveSince,
|
||||
game_started_at: status.gameStartedAt,
|
||||
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);
|
||||
current.capacity = currentCapacity(current, now);
|
||||
if (current.capacity === null) current.capacity_observed_at = null;
|
||||
// Apply lifecycle events immediately, until a newer status observes them
|
||||
// or confirms that the lobby has emptied.
|
||||
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 remember(current);
|
||||
return remember({
|
||||
...current,
|
||||
game_started_at:
|
||||
match.event === 'game_start' ? match.receivedAt : current.game_started_at,
|
||||
game_ended_at: match.event === 'game_end' ? match.receivedAt : null
|
||||
});
|
||||
});
|
||||
const names = new Set(snapshot.servers.map((server) => server.name));
|
||||
for (const name of observed.keys()) if (!names.has(name)) observed.delete(name);
|
||||
return next;
|
||||
}
|
||||
);
|
||||
|
||||
let snapshotServerNow = 0;
|
||||
function hydrateFleet(snapshot: { servers: FleetServer[]; operations: FleetOperation[]; integrations: NonNullable<ReturnType<typeof getIntegrations>>; serverNow: number; hostAttention?: number }) {
|
||||
function hydrateFleet(snapshot: {
|
||||
servers: FleetServer[];
|
||||
operations: FleetOperation[];
|
||||
integrations: FleetIntegrations;
|
||||
serverNow: number;
|
||||
hostAttention?: number;
|
||||
}) {
|
||||
if (!browser || snapshot.serverNow < snapshotServerNow) return;
|
||||
snapshotServerNow = snapshot.serverNow;
|
||||
syncServerTime(snapshot.serverNow);
|
||||
fleetIntegrations.set(snapshot.integrations);
|
||||
fleet.set({ servers: snapshot.servers, operations: snapshot.operations, hostAttention: snapshot.hostAttention,
|
||||
receivedAt: Date.now() - Math.max(0, serverTimeNow() - snapshot.serverNow) });
|
||||
fleet.set({
|
||||
servers: snapshot.servers,
|
||||
operations: snapshot.operations,
|
||||
hostAttention: snapshot.hostAttention,
|
||||
receivedAt: Date.now() - Math.max(0, serverTimeNow() - snapshot.serverNow)
|
||||
});
|
||||
}
|
||||
function getIntegrations() { return get(fleetIntegrations); }
|
||||
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let refreshRequested = false;
|
||||
function refreshFleet(afterCurrent = false): Promise<void> {
|
||||
if (!browser) return Promise.resolve();
|
||||
if (inFlight) { refreshRequested ||= afterCurrent; return inFlight; }
|
||||
if (inFlight) {
|
||||
refreshRequested ||= afterCurrent;
|
||||
return inFlight;
|
||||
}
|
||||
inFlight = (async () => {
|
||||
fleetRefreshing.set(true);
|
||||
const controller = new AbortController();
|
||||
@@ -91,36 +221,58 @@ export function createFleetStore(serverName?: string) {
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
(async () => {
|
||||
const response = await fetch(serverName === undefined ? '/api/servers' : `/api/servers/${encodeURIComponent(serverName)}`, { signal: controller.signal });
|
||||
if (!response.ok) throw new Error('Could not refresh servers. Retrying automatically.');
|
||||
const response = await fetch(
|
||||
serverName === undefined
|
||||
? '/api/servers'
|
||||
: `/api/servers/${encodeURIComponent(serverName)}`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error('Could not refresh servers. Retrying automatically.');
|
||||
const servers = await response.json();
|
||||
if (servers.serverNow < snapshotServerNow) return;
|
||||
snapshotServerNow = servers.serverNow;
|
||||
syncServerTime(servers.serverNow); fleetIntegrations.set(servers.integrations);
|
||||
fleet.update(value => ({ ...value, servers: servers.servers, receivedAt: requestedAt, hostAttention: servers.hostAttention,
|
||||
operations: serverName === undefined ? value.operations : servers.operations }));
|
||||
syncServerTime(servers.serverNow);
|
||||
fleetIntegrations.set(servers.integrations);
|
||||
fleet.update((value) => ({
|
||||
...value,
|
||||
servers: servers.servers,
|
||||
receivedAt: requestedAt,
|
||||
hostAttention: servers.hostAttention,
|
||||
operations:
|
||||
serverName === undefined ? value.operations : servers.operations
|
||||
}));
|
||||
})(),
|
||||
(async () => {
|
||||
if (serverName !== undefined) return;
|
||||
const response = await fetch('/api/fleet', { signal: controller.signal });
|
||||
if (!response.ok) throw new Error('Could not refresh operations. Retrying automatically.');
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
'Could not refresh operations. Retrying automatically.'
|
||||
);
|
||||
const operations = await response.json();
|
||||
fleet.update(value => ({ ...value, operations: operations.operations }));
|
||||
fleet.update((value) => ({ ...value, operations: operations.operations }));
|
||||
})()
|
||||
]);
|
||||
const failure = results.find(result => result.status === 'rejected');
|
||||
const failure = results.find((result) => result.status === 'rejected');
|
||||
if (failure?.status === 'rejected') throw failure.reason;
|
||||
fleetError.set('');
|
||||
} catch (error) {
|
||||
fleetError.set(error instanceof Error && error.name !== 'AbortError'
|
||||
? error.message : 'Fleet refresh timed out. Retrying automatically.');
|
||||
fleetError.set(
|
||||
error instanceof Error && error.name !== 'AbortError'
|
||||
? error.message
|
||||
: 'Fleet refresh timed out. Retrying automatically.'
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
fleetRefreshing.set(false);
|
||||
}
|
||||
})().finally(() => {
|
||||
inFlight = null;
|
||||
if (refreshRequested) { refreshRequested = false; void refreshFleet(); }
|
||||
if (refreshRequested) {
|
||||
refreshRequested = false;
|
||||
void refreshFleet();
|
||||
}
|
||||
});
|
||||
return inFlight;
|
||||
}
|
||||
@@ -128,30 +280,44 @@ export function createFleetStore(serverName?: string) {
|
||||
let stop: (() => void) | null = null;
|
||||
function startFleetMonitor(): () => void {
|
||||
if (!browser || stop) return () => {};
|
||||
const stopPolling = pollVisible(() => refreshFleet(), 60_000, !get(fleet).receivedAt || Date.now() - get(fleet).receivedAt > 30_000);
|
||||
const stopPolling = pollVisible(
|
||||
() => refreshFleet(),
|
||||
60_000,
|
||||
!get(fleet).receivedAt || Date.now() - get(fleet).receivedAt > 30_000
|
||||
);
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let stopped = false;
|
||||
let pending = false;
|
||||
let refreshing = false;
|
||||
let lastEventRefresh = -Infinity;
|
||||
const schedule = () => {
|
||||
if (stopped || refreshing || refreshTimer !== undefined || !pending || document.visibilityState === 'hidden') return;
|
||||
refreshTimer = setTimeout(async () => {
|
||||
refreshTimer = undefined;
|
||||
refreshing = true;
|
||||
try {
|
||||
// Wait for any manual/poll refresh, then fetch evidence newer than
|
||||
// the event. Do not use refreshFleet(true)'s immediate follow-up.
|
||||
if (inFlight) await inFlight;
|
||||
if (stopped || document.visibilityState === 'hidden') return;
|
||||
pending = false;
|
||||
lastEventRefresh = Date.now();
|
||||
await refreshFleet();
|
||||
} finally {
|
||||
refreshing = false;
|
||||
schedule();
|
||||
}
|
||||
}, Math.max(250, lastEventRefresh + 2000 - Date.now()));
|
||||
if (
|
||||
stopped ||
|
||||
refreshing ||
|
||||
refreshTimer !== undefined ||
|
||||
!pending ||
|
||||
document.visibilityState === 'hidden'
|
||||
)
|
||||
return;
|
||||
refreshTimer = setTimeout(
|
||||
async () => {
|
||||
refreshTimer = undefined;
|
||||
refreshing = true;
|
||||
try {
|
||||
// Wait for any manual/poll refresh, then fetch evidence newer than
|
||||
// the event. Do not use refreshFleet(true)'s immediate follow-up.
|
||||
if (inFlight) await inFlight;
|
||||
if (stopped || document.visibilityState === 'hidden') return;
|
||||
pending = false;
|
||||
lastEventRefresh = Date.now();
|
||||
await refreshFleet();
|
||||
} finally {
|
||||
refreshing = false;
|
||||
schedule();
|
||||
}
|
||||
},
|
||||
Math.max(250, lastEventRefresh + 2000 - Date.now())
|
||||
);
|
||||
};
|
||||
const changed = () => {
|
||||
if (document.visibilityState === 'hidden') return;
|
||||
@@ -161,11 +327,32 @@ export function createFleetStore(serverName?: string) {
|
||||
window.addEventListener('ec:fleet-changed', changed);
|
||||
stop = () => {
|
||||
stopped = true;
|
||||
stopPolling(); clearTimeout(refreshTimer); window.removeEventListener('ec:fleet-changed', changed); stop = null;
|
||||
stopPolling();
|
||||
clearTimeout(refreshTimer);
|
||||
window.removeEventListener('ec:fleet-changed', changed);
|
||||
stop = null;
|
||||
};
|
||||
return stop;
|
||||
}
|
||||
return { fleet, fleetServers, fleetError, fleetRefreshing, fleetIntegrations, hydrateFleet, refreshFleet, startFleetMonitor };
|
||||
return {
|
||||
fleet,
|
||||
fleetServers,
|
||||
fleetError,
|
||||
fleetRefreshing,
|
||||
fleetIntegrations,
|
||||
hydrateFleet,
|
||||
refreshFleet,
|
||||
startFleetMonitor
|
||||
};
|
||||
}
|
||||
|
||||
export const { fleet, fleetServers, fleetError, fleetRefreshing, fleetIntegrations, hydrateFleet, refreshFleet, startFleetMonitor } = createFleetStore();
|
||||
export const {
|
||||
fleet,
|
||||
fleetServers,
|
||||
fleetError,
|
||||
fleetRefreshing,
|
||||
fleetIntegrations,
|
||||
hydrateFleet,
|
||||
refreshFleet,
|
||||
startFleetMonitor
|
||||
} = createFleetStore();
|
||||
|
||||
@@ -27,20 +27,37 @@ export function startPipelineStream(): (() => void) | undefined {
|
||||
if (!browser || source) return;
|
||||
source = new EventSource('/api/events/pipeline');
|
||||
source.addEventListener('open', () => window.dispatchEvent(new Event('ec:pipeline-connected')));
|
||||
source.addEventListener('beta-changed', event => {
|
||||
try { window.dispatchEvent(new CustomEvent('ec:beta-changed', { detail: JSON.parse((event as MessageEvent).data) })); }
|
||||
catch { /* Reconnect refreshes authoritative state if an event is malformed. */ }
|
||||
source.addEventListener('beta-changed', (event) => {
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('ec:beta-changed', {
|
||||
detail: JSON.parse((event as MessageEvent).data)
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
/* Reconnect refreshes authoritative state if an event is malformed. */
|
||||
}
|
||||
});
|
||||
for (const event of ['server-changed', 'server-state', 'deployment-opened', 'fleet-invalidated']) {
|
||||
for (const event of [
|
||||
'server-changed',
|
||||
'server-state',
|
||||
'deployment-opened',
|
||||
'fleet-invalidated'
|
||||
]) {
|
||||
source.addEventListener(event, () => window.dispatchEvent(new Event('ec:fleet-changed')));
|
||||
}
|
||||
|
||||
source.addEventListener('deployment-opened', (evt) => {
|
||||
const data = JSON.parse((evt as MessageEvent).data) as { deploymentId: string; action: string; servers: string[] };
|
||||
const data = JSON.parse((evt as MessageEvent).data) as {
|
||||
deploymentId: string;
|
||||
action: string;
|
||||
servers: string[];
|
||||
};
|
||||
updateDeployment(data.deploymentId, (d) => {
|
||||
d.action = data.action;
|
||||
for (const s of data.servers) {
|
||||
if (!d.servers.has(s)) d.servers.set(s, { state: 'pending', players: null, error: null });
|
||||
if (!d.servers.has(s))
|
||||
d.servers.set(s, { state: 'pending', players: null, error: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -54,8 +71,15 @@ export function startPipelineStream(): (() => void) | undefined {
|
||||
error: string | null;
|
||||
};
|
||||
updateDeployment(data.deploymentId, (d) => {
|
||||
d.servers.set(data.server, { state: data.state, players: data.players, error: data.error });
|
||||
d.servers.set(data.server, {
|
||||
state: data.state,
|
||||
players: data.players,
|
||||
error: data.error
|
||||
});
|
||||
});
|
||||
});
|
||||
return () => { source?.close(); source = null; };
|
||||
return () => {
|
||||
source?.close();
|
||||
source = null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ 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';
|
||||
export type RconFailure =
|
||||
'no-config' | 'no-port' | 'send-error' | 'timeout' | 'bad-reply' | 'no-players';
|
||||
|
||||
export interface RconEndpoint {
|
||||
host: string;
|
||||
@@ -79,7 +80,7 @@ let source: EventSource | null = null;
|
||||
export function startServerStream(): (() => void) | undefined {
|
||||
if (!browser || source) return;
|
||||
syncServerTime();
|
||||
const stream = source = new EventSource('/api/events/servers');
|
||||
const stream = (source = new EventSource('/api/events/servers'));
|
||||
let stopped = false;
|
||||
let frame: number | null = null;
|
||||
let pendingStatus: Map<string, ServerStatus> | null = null;
|
||||
@@ -89,20 +90,31 @@ export function startServerStream(): (() => void) | undefined {
|
||||
const flush = () => {
|
||||
frame = null;
|
||||
if (stopped) return;
|
||||
const statuses = pendingStatus, telemetry = pendingTelemetry, matches = pendingMatches;
|
||||
const statuses = pendingStatus,
|
||||
telemetry = pendingTelemetry,
|
||||
matches = pendingMatches;
|
||||
const statusAt = receivedAt;
|
||||
pendingStatus = pendingTelemetry = pendingMatches = null;
|
||||
// Publish the latest clock before exposing reports, including after a
|
||||
// suspended frame. Every changed store is published at most once per frame.
|
||||
syncServerTime();
|
||||
if (statuses) { statusByServer.set(statuses); statusReceivedAt.set(statusAt); }
|
||||
if (statuses) {
|
||||
statusByServer.set(statuses);
|
||||
statusReceivedAt.set(statusAt);
|
||||
}
|
||||
if (matches) matchEventsByServer.set(matches);
|
||||
if (telemetry) telemetryByServer.set(telemetry);
|
||||
};
|
||||
const schedule = () => { if (frame === null) frame = requestAnimationFrame(flush); };
|
||||
const schedule = () => {
|
||||
if (frame === null) frame = requestAnimationFrame(flush);
|
||||
};
|
||||
stream.addEventListener('server-telemetry', (evt) => {
|
||||
if (stopped) return;
|
||||
const data = JSON.parse((evt as MessageEvent).data) as { entries: GameTelemetry[]; snapshot?: boolean; serverNow?: number };
|
||||
const data = JSON.parse((evt as MessageEvent).data) as {
|
||||
entries: GameTelemetry[];
|
||||
snapshot?: boolean;
|
||||
serverNow?: number;
|
||||
};
|
||||
updateServerClock(data.serverNow);
|
||||
// Merge into one private map per frame. Snapshots replace all earlier
|
||||
// evidence; later deltas still apply in arrival order.
|
||||
@@ -122,7 +134,11 @@ export function startServerStream(): (() => void) | undefined {
|
||||
});
|
||||
stream.addEventListener('server-status', (evt) => {
|
||||
if (stopped) return;
|
||||
const data = JSON.parse((evt as MessageEvent).data) as { entries: ServerStatus[]; serverNow?: number; delta?: boolean };
|
||||
const data = JSON.parse((evt as MessageEvent).data) as {
|
||||
entries: ServerStatus[];
|
||||
serverNow?: number;
|
||||
delta?: boolean;
|
||||
};
|
||||
updateServerClock(data.serverNow);
|
||||
receivedAt = Date.now();
|
||||
if (!data.delta) pendingStatus = new Map();
|
||||
@@ -131,14 +147,18 @@ export function startServerStream(): (() => void) | undefined {
|
||||
schedule();
|
||||
});
|
||||
if (tickInterval === null) {
|
||||
tickInterval = setInterval(() => { if (typeof document === 'undefined' || document.visibilityState !== 'hidden') _uiNow.set(serverTimeNow()); }, 1000);
|
||||
tickInterval = setInterval(() => {
|
||||
if (typeof document === 'undefined' || document.visibilityState !== 'hidden')
|
||||
_uiNow.set(serverTimeNow());
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
pendingStatus = pendingTelemetry = pendingMatches = null;
|
||||
stream.close(); source = null;
|
||||
stream.close();
|
||||
source = null;
|
||||
if (tickInterval) clearInterval(tickInterval);
|
||||
tickInterval = null;
|
||||
};
|
||||
|
||||
@@ -9,18 +9,31 @@ export const timeZonePersistenceError = writable(false);
|
||||
/** Called only on mount so server rendering never shares a user's preference. */
|
||||
export function startTimeZonePreference(): () => void {
|
||||
let saved: string | null = null;
|
||||
try { saved = localStorage.getItem(TIME_ZONE_STORAGE_KEY); } catch { /* Use the default. */ }
|
||||
try {
|
||||
saved = localStorage.getItem(TIME_ZONE_STORAGE_KEY);
|
||||
} catch {
|
||||
/* Use the default. */
|
||||
}
|
||||
timeZone.set(validTimeZone(saved) ? saved : DEFAULT_TIME_ZONE);
|
||||
const unsubscribe = timeZone.subscribe(value => {
|
||||
const unsubscribe = timeZone.subscribe((value) => {
|
||||
try {
|
||||
localStorage.setItem(TIME_ZONE_STORAGE_KEY, value);
|
||||
timeZonePersistenceError.set(false);
|
||||
} catch { timeZonePersistenceError.set(true); }
|
||||
} catch {
|
||||
timeZonePersistenceError.set(true);
|
||||
}
|
||||
});
|
||||
function onStorage(event: StorageEvent) {
|
||||
if (event.storageArea !== localStorage || (event.key !== TIME_ZONE_STORAGE_KEY && event.key !== null)) return;
|
||||
if (
|
||||
event.storageArea !== localStorage ||
|
||||
(event.key !== TIME_ZONE_STORAGE_KEY && event.key !== null)
|
||||
)
|
||||
return;
|
||||
timeZone.set(validTimeZone(event.newValue) ? event.newValue : DEFAULT_TIME_ZONE);
|
||||
}
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => { unsubscribe(); window.removeEventListener('storage', onStorage); };
|
||||
return () => {
|
||||
unsubscribe();
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user