Refresh Fleet with discovery, operation progress, and lobby watches
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import { statusByServer, statusReceivedAt } 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';
|
||||
|
||||
export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperation[]; receivedAt: number }>({
|
||||
servers: [], operations: [], receivedAt: 0
|
||||
});
|
||||
export const fleetError = writable('');
|
||||
export const fleetRefreshing = writable(false);
|
||||
export const lobbyWatches = writable<LobbyWatch[]>([]);
|
||||
|
||||
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt], ([snapshot, live, received]) =>
|
||||
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,
|
||||
last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError,
|
||||
roster: status.roster, health: status.health, active_since: status.activeSince,
|
||||
inactive_since: status.inactiveSince, game_started_at: status.gameStartedAt,
|
||||
game_ended_at: status.gameEndedAt } : server;
|
||||
}));
|
||||
|
||||
let inFlight: Promise<void> | null = null;
|
||||
export function refreshFleet(): Promise<void> {
|
||||
if (!browser) return Promise.resolve();
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
fleetRefreshing.set(true);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 12_000);
|
||||
const requestedAt = Date.now();
|
||||
try {
|
||||
const responses = await Promise.all([
|
||||
fetch('/api/servers', { signal: controller.signal }),
|
||||
fetch('/api/fleet', { signal: controller.signal })
|
||||
]);
|
||||
if (responses.some(response => !response.ok)) throw new Error('Could not refresh Fleet. Retrying automatically.');
|
||||
const [servers, operations] = await Promise.all(responses.map(response => response.json()));
|
||||
fleetError.set('');
|
||||
fleet.set({ servers: servers.servers, operations: operations.operations, receivedAt: requestedAt });
|
||||
} catch (error) {
|
||||
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; });
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
export function followLobby(server: string, kind: LobbyWatch['kind'], operation?: FleetOperation): void {
|
||||
const id = `${server}:${kind}:${operation?.key ?? ''}`;
|
||||
lobbyWatches.update(watches => watches.some(watch => watch.id === id) ? watches : [...watches, {
|
||||
id, server, kind, operationKey: operation?.key,
|
||||
href: operation?.href ?? `/servers/${encodeURIComponent(server)}`
|
||||
}]);
|
||||
toast('Watching this lobby while the app is open.');
|
||||
}
|
||||
|
||||
export function cancelWatch(id: string): void {
|
||||
lobbyWatches.update(watches => watches.filter(watch => watch.id !== id));
|
||||
}
|
||||
|
||||
let stop: (() => void) | null = null;
|
||||
export function startFleetMonitor(): () => void {
|
||||
if (!browser || stop) return () => {};
|
||||
try {
|
||||
const saved: unknown = JSON.parse(sessionStorage.getItem('electric-cherry:lobby-watches') ?? '[]');
|
||||
if (Array.isArray(saved)) lobbyWatches.set(saved.filter((watch): watch is LobbyWatch =>
|
||||
watch && typeof watch.id === 'string' && typeof watch.server === 'string'
|
||||
&& ['empty', 'online', 'operation'].includes(watch.kind)
|
||||
&& typeof watch.href === 'string' && /^\/(servers|campaigns|deployments)\//.test(watch.href)
|
||||
&& (watch.kind !== 'operation' || typeof watch.operationKey === 'string')));
|
||||
} catch { /* Watches still work with session storage disabled. */ }
|
||||
const stopSaving = lobbyWatches.subscribe(watches => {
|
||||
try { sessionStorage.setItem('electric-cherry:lobby-watches', JSON.stringify(watches)); } catch { /* Optional storage. */ }
|
||||
});
|
||||
void refreshFleet();
|
||||
const interval = setInterval(() => void refreshFleet(), 10_000);
|
||||
const onFocus = () => { if (document.visibilityState === 'visible') void refreshFleet(); };
|
||||
document.addEventListener('visibilitychange', onFocus);
|
||||
const check = () => {
|
||||
// Never complete a watch using an operation snapshot from a failed refresh.
|
||||
if (get(fleetError)) return;
|
||||
const snapshot = get(fleet);
|
||||
if (!snapshot.receivedAt || Date.now() - snapshot.receivedAt > 30_000) return;
|
||||
const servers = get(fleetServers);
|
||||
for (const watch of get(lobbyWatches)) {
|
||||
if (!watchSatisfied(watch, servers.find(server => server.name === watch.server), snapshot.operations, Date.now())) continue;
|
||||
const message = `${serverTitle(watch.server)} ${watch.kind === 'empty' ? 'is empty' : watch.kind === 'online' ? 'is back online' : 'has finished its operation'}.`;
|
||||
cancelWatch(watch.id);
|
||||
toast(message, { variant: 'success', ttl: 0, href: watch.href });
|
||||
}
|
||||
};
|
||||
const unsubscribe = fleetServers.subscribe(check);
|
||||
stop = () => {
|
||||
clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', onFocus);
|
||||
unsubscribe();
|
||||
stopSaving();
|
||||
stop = null;
|
||||
};
|
||||
return stop;
|
||||
}
|
||||
Reference in New Issue
Block a user