Remove fleet lobby follow notifications

This commit is contained in:
2026-09-21 19:33:57 -04:00
parent e448457d58
commit f875260b7e
5 changed files with 10 additions and 123 deletions
-6
View File
@@ -27,12 +27,6 @@ removal, and restart-recovery state appears alongside affected servers. Fleet
membership and operation progress refresh every ten seconds, with live server membership and operation progress refresh every ten seconds, with live server
status between refreshes. Failed refreshes keep the last snapshot visible. status between refreshes. Failed refreshes keep the last snapshot visible.
**Follow lobby** can notify you when a server is freshly confirmed empty, returns
online, or a selected campaign/server operation completes. Watches run while the
app is open, continue across navigation, and survive reloads in the same tab.
Notifications include a link and remain until dismissed. Watches do not perform
server actions or send browser push notifications after the app closes.
## Stack ## Stack
- SvelteKit (adapter-node) + TypeScript - SvelteKit (adapter-node) + TypeScript
-18
View File
@@ -87,21 +87,3 @@ export function matchesFleet(server: FleetServer, query: string, filter: FleetFi
...(server.roster ?? []).map(player => player.name)].join(' ').toLocaleLowerCase(); ...(server.roster ?? []).map(player => player.name)].join(' ').toLocaleLowerCase();
return query.trim().toLocaleLowerCase().split(/\s+/).every(term => text.includes(term)); return query.trim().toLocaleLowerCase().split(/\s+/).every(term => text.includes(term));
} }
export interface LobbyWatch {
id: string;
server: string;
kind: 'empty' | 'online' | 'operation';
operationKey?: string;
href: string;
}
export function watchSatisfied(watch: LobbyWatch, server: FleetServer | undefined,
operations: readonly FleetOperation[], now: number): boolean {
if (watch.kind === 'operation') {
// Disappearance, failure and replacement by another operation are not completion.
return operations.some(op => op.key === watch.operationKey && op.state === 'done');
}
if (!server || !freshServer(server, now)) return false;
return watch.kind === 'online' || server.last_players === 0;
}
+4 -47
View File
@@ -1,9 +1,7 @@
import { derived, get, writable } from 'svelte/store'; import { derived, writable } from 'svelte/store';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { statusByServer, statusReceivedAt, serverTimeNow, syncServerTime, matchEventsByServer } from './servers.js'; import { statusByServer, statusReceivedAt, syncServerTime, matchEventsByServer } from './servers.js';
import { watchSatisfied, type FleetServer, type FleetOperation, type LobbyWatch } from '$lib/fleet-overview.js'; import { type FleetServer, type FleetOperation } 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 }>({ export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperation[]; receivedAt: number }>({
servers: [], operations: [], receivedAt: 0 servers: [], operations: [], receivedAt: 0
@@ -11,7 +9,6 @@ export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperati
export const fleetError = writable(''); export const fleetError = writable('');
export const fleetRefreshing = writable(false); export const fleetRefreshing = writable(false);
export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string } | null>(null); 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, matchEventsByServer], ([snapshot, live, received, matches]) => export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer], ([snapshot, live, received, matches]) =>
snapshot.servers.map(server => { snapshot.servers.map(server => {
@@ -62,58 +59,18 @@ export function refreshFleet(): Promise<void> {
return inFlight; 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; let stop: (() => void) | null = null;
export function startFleetMonitor(): () => void { export function startFleetMonitor(): () => void {
if (!browser || stop) return () => {}; 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(); void refreshFleet();
const interval = setInterval(() => { const interval = setInterval(() => {
if (document.visibilityState !== 'hidden' || get(lobbyWatches).length) void refreshFleet(); if (document.visibilityState !== 'hidden') void refreshFleet();
}, 10_000); }, 10_000);
const onFocus = () => { if (document.visibilityState === 'visible') void refreshFleet(); }; const onFocus = () => { if (document.visibilityState === 'visible') void refreshFleet(); };
document.addEventListener('visibilitychange', onFocus); 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, serverTimeNow())) 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 = () => { stop = () => {
clearInterval(interval); clearInterval(interval);
document.removeEventListener('visibilitychange', onFocus); document.removeEventListener('visibilitychange', onFocus);
unsubscribe();
stopSaving();
stop = null; stop = null;
}; };
return stop; return stop;
+4 -34
View File
@@ -9,7 +9,7 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import StatusPip from '$ui/components/primitives/StatusPip.svelte'; import StatusPip from '$ui/components/primitives/StatusPip.svelte';
import { fleet, fleetServers, fleetError, fleetRefreshing, fleetIntegrations, refreshFleet, lobbyWatches, followLobby, cancelWatch } from '$ui/stores/fleet.js'; import { fleet, fleetServers, fleetError, fleetRefreshing, fleetIntegrations, refreshFleet } from '$ui/stores/fleet.js';
import { telemetryByServer, uiNow } from '$ui/stores/servers.js'; import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js'; import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js';
import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js'; import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js';
@@ -119,16 +119,6 @@
function launch(selection: { game: ZombieGame; slug: string } | null = null) { function launch(selection: { game: ZombieGame; slug: string } | null = null) {
initialGame = selection?.game ?? 't5'; initialMapSlug = selection?.slug ?? ''; deployOpen = true; initialGame = selection?.game ?? 't5'; initialMapSlug = selection?.slug ?? ''; deployOpen = true;
} }
function watch(server: FleetServer, event: Event) {
const input = event.currentTarget as HTMLSelectElement;
const value = input.value;
input.value = '';
if (value === 'empty' || value === 'online') followLobby(server.name, value);
else {
const operation = activeOperations.find(op => op.key === value);
if (operation) followLobby(server.name, 'operation', operation);
}
}
function resetFilters() { query = ''; filter = 'all'; game = ''; } function resetFilters() { query = ''; filter = 'all'; game = ''; }
</script> </script>
@@ -183,18 +173,9 @@
</a> </a>
{/if} {/if}
{#if activeOperations.length || $lobbyWatches.length} {#if activeOperations.length}
<details id="fleet-operations" class="operations" bind:open={operationsOpen}> <details id="fleet-operations" class="operations" bind:open={operationsOpen}>
<summary>Activity · {activeOperations.length} operations{ $lobbyWatches.length ? ` · ${$lobbyWatches.length} lobby watches` : ''}{attentionOperations.length ? ` · ${attentionOperations.length} need attention` : ''}</summary> <summary>Activity · {activeOperations.length} operations{attentionOperations.length ? ` · ${attentionOperations.length} need attention` : ''}</summary>
{#if $lobbyWatches.length}
<div class="watches" aria-label="Lobby watches">
<div><strong>Following {$lobbyWatches.length} {$lobbyWatches.length === 1 ? 'condition' : 'conditions'}</strong><span class="hint">Notifications stay active as you navigate. Keep this app open.</span></div>
{#each $lobbyWatches as item (item.id)}
<button title="Cancel this watch" onclick={() => cancelWatch(item.id)} aria-label="Cancel {item.kind} watch for {serverTitle(item.server)}">{serverTitle(item.server)} · {item.kind === 'empty' ? 'when empty' : item.kind === 'online' ? 'when online' : 'when complete'} ×</button>
{/each}
</div>
{/if}
<div class="operation-list">{#each activeOperations as operation (operation.key)} <div class="operation-list">{#each activeOperations as operation (operation.key)}
<a href={operation.href} class:issue={operation.state === 'attention'}><strong>{serverTitle(operation.server)}</strong><span>{operation.label}</span><small>{operation.detail}</small></a> <a href={operation.href} class:issue={operation.state === 'attention'}><strong>{serverTitle(operation.server)}</strong><span>{operation.label}</span><small>{operation.detail}</small></a>
{/each}</div> {/each}</div>
@@ -269,14 +250,6 @@
{#snippet serverActions(server: FleetServer)} {#snippet serverActions(server: FleetServer)}
<button class="pin" class:selected={pinned.includes(server.name)} aria-pressed={pinned.includes(server.name)} aria-label="Pin {serverTitle(server.name)}" onclick={() => togglePin(server.name)}>{pinned.includes(server.name) ? '★ Pinned' : '☆ Pin'}</button> <button class="pin" class:selected={pinned.includes(server.name)} aria-pressed={pinned.includes(server.name)} aria-label="Pin {serverTitle(server.name)}" onclick={() => togglePin(server.name)}>{pinned.includes(server.name) ? '★ Pinned' : '☆ Pin'}</button>
<select class="watch-select" aria-label="Follow {serverTitle(server.name)}" onchange={event => watch(server, event)}>
<option value="">Follow lobby…</option>
<option value="empty" disabled={freshServer(server, $uiNow) && server.last_players === 0}>Tell me when it’s empty</option>
<option value="online" disabled={freshServer(server, $uiNow)}>Tell me when it’s online</option>
{#each activeOperations.filter(op => op.server === server.name && (op.kind === 'campaign' || op.kind === 'deployment') && op.state === 'active') as operation (operation.key)}
<option value={operation.key}>When complete: {operation.label}</option>
{/each}
</select>
{/snippet} {/snippet}
<style> <style>
@@ -319,7 +292,7 @@
input::placeholder { color: var(--text-secondary); } input::placeholder { color: var(--text-secondary); }
.filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; } .filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.chips, .view-toggle { display: flex; gap: 6px; flex-wrap: wrap; } .chips, .view-toggle { display: flex; gap: 6px; flex-wrap: wrap; }
.chips button, .view-toggle button, .pin, .watches button { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); font-size: 12px; } .chips button, .view-toggle button, .pin { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); font-size: 12px; }
.chips button span { margin-left: 8px; opacity: .75; font-variant-numeric: tabular-nums; } .chips button span { margin-left: 8px; opacity: .75; font-variant-numeric: tabular-nums; }
button.selected { background: color-mix(in srgb, var(--accent-primary) 15%, var(--surface)); color: var(--accent-primary-hover); border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); } button.selected { background: color-mix(in srgb, var(--accent-primary) 15%, var(--surface)); color: var(--accent-primary-hover); border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
.result-count { justify-content: flex-start; font-size: 12px; color: var(--text-secondary); } .result-count { justify-content: flex-start; font-size: 12px; color: var(--text-secondary); }
@@ -334,7 +307,6 @@
.server-tile :global(.card) { border-radius: 0; } .server-tile :global(.card) { border-radius: 0; }
.tile-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border-subtle); } .tile-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border-subtle); }
.pin { white-space: nowrap; } .pin { white-space: nowrap; }
.watch-select { font-size: 12px; min-height: 36px; min-width: 0; width: 145px; padding: 6px; }
.server-operations { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; font-size: 12px; } .server-operations { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; font-size: 12px; }
.server-operations a { text-decoration: none; color: var(--accent-primary-hover); overflow-wrap: anywhere; } .server-operations a { text-decoration: none; color: var(--accent-primary-hover); overflow-wrap: anywhere; }
.server-operations small { display: block; margin-top: 5px; color: var(--text-secondary); } .server-operations small { display: block; margin-top: 5px; color: var(--text-secondary); }
@@ -344,8 +316,6 @@
.operation-list { display: grid; gap: 10px; margin-top: 16px; } .operation-list { display: grid; gap: 10px; margin-top: 16px; }
.operation-list a { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; padding: 12px; text-decoration: none; background: var(--surface); border-radius: var(--radius-md); color: var(--text-primary); } .operation-list a { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; padding: 12px; text-decoration: none; background: var(--surface); border-radius: var(--radius-md); color: var(--text-primary); }
.operation-list small { grid-column: 1 / -1; color: var(--text-secondary); overflow-wrap: anywhere; } .operation-list small { grid-column: 1 / -1; color: var(--text-secondary); overflow-wrap: anywhere; }
.watches { margin-top: 16px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: 13px; }
.watches > div { margin-right: auto; }
.error { padding: 16px; border: 1px solid var(--state-warning); border-radius: var(--radius-md); font-size: 13px; line-height: 1.7; } .error { padding: 16px; border: 1px solid var(--state-warning); border-radius: var(--radius-md); font-size: 13px; line-height: 1.7; }
.error button { margin-left: 8px; } .error button { margin-left: 8px; }
.loading { padding: 70px 20px; text-align: center; color: var(--text-secondary); background: var(--surface); border-radius: var(--radius-lg); } .loading { padding: 70px 20px; text-align: center; color: var(--text-secondary); background: var(--surface); border-radius: var(--radius-lg); }
+2 -18
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { currentRound, fleetIdentity, matchesFleet, watchSatisfied, type FleetServer, type FleetOperation } from '../../src/lib/fleet-overview.js'; import { currentRound, fleetIdentity, matchesFleet, type FleetServer, type FleetOperation } from '../../src/lib/fleet-overview.js';
import { gameEvent } from '../helpers/telemetry.js'; import { gameEvent } from '../helpers/telemetry.js';
import { newFixture, type Fixture } from '../helpers/fixture.js'; import { newFixture, type Fixture } from '../helpers/fixture.js';
@@ -54,22 +54,6 @@ describe('Fleet discovery and match freshness', () => {
}); });
}); });
describe('one-shot lobby watches', () => {
it('requires a fresh zero-player reading, never an absent server or unknown count', () => {
const watch = { id: 'empty', server: server.name, kind: 'empty' as const, href: '/' };
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(-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: '/' };
expect(watchSatisfied(watch, server, [], now)).toBe(false);
expect(watchSatisfied(watch, server, [{ ...operation, state: 'attention' }], now)).toBe(false);
expect(watchSatisfied(watch, server, [{ ...operation, key: 'different', state: 'done' }], now)).toBe(false);
expect(watchSatisfied(watch, undefined, [{ ...operation, state: 'done' }], now)).toBe(true);
});
});
describe('persisted Fleet operations', () => { describe('persisted Fleet operations', () => {
let fixture: Fixture; let fixture: Fixture;
@@ -95,7 +79,7 @@ describe('persisted Fleet operations', () => {
expect(fleetOperations(now)[0].state).toBe('done'); expect(fleetOperations(now)[0].state).toBe('done');
}); });
it('keeps lifecycle failures actionable until superseded and retains successful completions for watches', async () => { it('keeps lifecycle failures actionable until superseded and retains successful completions', async () => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const handle = db(); const handle = db();
const insert = handle.prepare("INSERT INTO server_deployments (id, server, action, state, stage, started_at, finished_at, triggered_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'test')"); const insert = handle.prepare("INSERT INTO server_deployments (id, server, action, state, stage, started_at, finished_at, triggered_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'test')");