Refresh Fleet with discovery, operation progress, and lobby watches
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { parseServerName, zombieMapBySlug } from './map-aliases.js';
|
||||
import type { ZombieGame } from './map-aliases.js';
|
||||
import type { RoundTelemetry } from './telemetry.js';
|
||||
import type { Health, RosterPlayer } from './ui/stores/servers.js';
|
||||
import { mapTitle, serverTitle } from './ui/server-labels.js';
|
||||
|
||||
export interface FleetServer {
|
||||
name: string;
|
||||
game: string | null;
|
||||
map: string | null;
|
||||
map_slug?: string | null;
|
||||
game_mode?: string | null;
|
||||
last_players: number | null;
|
||||
last_map: string | null;
|
||||
last_gametype: string | null;
|
||||
last_seen_at: string | null;
|
||||
last_error: string | null;
|
||||
roster: RosterPlayer[] | null;
|
||||
health: Health;
|
||||
held: { target: string; betaId: number }[];
|
||||
active_since: string | null;
|
||||
inactive_since: string | null;
|
||||
game_started_at: string | null;
|
||||
game_ended_at: string | null;
|
||||
}
|
||||
|
||||
export interface FleetOperation {
|
||||
key: string;
|
||||
group: string;
|
||||
server: string;
|
||||
kind: 'campaign' | 'deployment' | 'beta' | 'recovery';
|
||||
label: string;
|
||||
detail: string;
|
||||
href: string;
|
||||
state: 'active' | 'attention' | 'done';
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export type FleetFilter = 'all' | 'occupied' | 'empty' | 'attention' | 'testing' | 'pinned';
|
||||
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;
|
||||
}
|
||||
|
||||
export function fleetIdentity(server: FleetServer) {
|
||||
const parsed = parseServerName(server.name);
|
||||
const game = server.game ?? parsed?.game ?? '';
|
||||
const mode = server.game_mode ?? parsed?.gameMode;
|
||||
const selection = !mode || mode === 'zombie'
|
||||
? zombieMapBySlug(game, server.map_slug ?? parsed?.mapSlug ?? '') : undefined;
|
||||
const map = server.last_map ?? server.map;
|
||||
// A live map change must not keep the registered map's launch shortcut.
|
||||
const matchingSelection = selection && (!map || selection.canonical === map) ? selection : undefined;
|
||||
return {
|
||||
game,
|
||||
title: serverTitle(server.name),
|
||||
map: matchingSelection?.friendly ?? mapTitle(map),
|
||||
group: `${game}:${matchingSelection?.slug ?? map ?? 'unknown'}`,
|
||||
launch: matchingSelection && (game === 't5' || game === 't6')
|
||||
? { game: game as ZombieGame, slug: matchingSelection.slug } : null
|
||||
};
|
||||
}
|
||||
|
||||
export function currentRound(server: FleetServer, report: RoundTelemetry | undefined, now: number): number | null {
|
||||
if (!report || !freshServer(server, now) || !server.active_since || !server.game_started_at
|
||||
|| server.game_ended_at || (server.last_players ?? 0) === 0) return null;
|
||||
const received = Date.parse(report.receivedAt);
|
||||
const age = now - received;
|
||||
return age >= 0 && age <= 120_000 && received >= Date.parse(server.game_started_at)
|
||||
&& received >= Date.parse(server.active_since) ? report.round : null;
|
||||
}
|
||||
|
||||
export function matchesFleet(server: FleetServer, query: string, filter: FleetFilter, game: string,
|
||||
pinned: readonly string[], operations: readonly FleetOperation[], now: number): boolean {
|
||||
const identity = fleetIdentity(server);
|
||||
if (game && identity.game !== game) return false;
|
||||
const fresh = freshServer(server, now);
|
||||
if (filter === 'occupied' && !(fresh && (server.last_players ?? 0) > 0)) return false;
|
||||
if (filter === 'empty' && !(fresh && server.last_players === 0)) return false;
|
||||
if (filter === 'attention' && !(server.health === 'offline' || server.health === 'stale'
|
||||
|| (server.health === 'online' && !fresh) || operations.some(op => op.server === server.name && op.state === 'attention'))) return false;
|
||||
if (filter === 'testing' && !server.held.length) return false;
|
||||
if (filter === 'pinned' && !pinned.includes(server.name)) return false;
|
||||
const text = [server.name, identity.title, identity.map, identity.game,
|
||||
...(server.roster ?? []).map(player => player.name)].join(' ').toLocaleLowerCase();
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { db } from '../db/index.js';
|
||||
import type { FleetOperation } from '../../fleet-overview.js';
|
||||
|
||||
/** Read persisted work, including recent completions for one-shot lobby watches. */
|
||||
export function fleetOperations(now = Date.now()): FleetOperation[] {
|
||||
const since = new Date(now - 86_400_000).toISOString();
|
||||
const handle = db();
|
||||
const operations: FleetOperation[] = [];
|
||||
const slots = handle.prepare('SELECT server, requested_at, error FROM restart_rollout WHERE healthy_at IS NULL')
|
||||
.all() as { server: string; requested_at: string; error: string | null }[];
|
||||
const recovering = new Map(slots.map(slot => [slot.server, slot]));
|
||||
const representedRecovery = new Set<string>();
|
||||
const campaigns = handle.prepare(`SELECT d.id, d.action, d.started_at, ds.server,
|
||||
ds.restart_state, ds.error, ds.restarted_at, ds.players
|
||||
FROM deployments d JOIN deployment_servers ds ON ds.deployment_id = d.id
|
||||
WHERE ds.restart_state != 'done' OR ds.checked_at >= ? OR d.finished_at >= ? OR d.started_at >= ?`).all(since, since, since) as {
|
||||
id: string; action: string; started_at: string; server: string; restart_state: string;
|
||||
error: string | null; restarted_at: string | null; players: number | null;
|
||||
}[];
|
||||
for (const row of campaigns) {
|
||||
const state = row.restart_state === 'done' ? 'done'
|
||||
: ['failed', 'manual'].includes(row.restart_state) ? 'attention' : 'active';
|
||||
const slot = recovering.get(row.server);
|
||||
if (row.restarted_at && state !== 'done') representedRecovery.add(row.server);
|
||||
const label = state === 'done' ? 'Update complete' : state === 'attention' ? 'Update needs attention'
|
||||
: row.restart_state === 'held' ? 'Held by a beta'
|
||||
: row.restarted_at ? 'Recovering' : (row.players ?? 0) > 0 ? 'Waiting for an empty lobby' : 'Queued for restart';
|
||||
operations.push({ key: `campaign:${row.id}:${row.server}`, group: `campaign:${row.id}`,
|
||||
kind: 'campaign', server: row.server, label, state, startedAt: row.started_at,
|
||||
detail: state === 'done' ? 'Update applied and restart checks completed' : row.error ?? (row.restarted_at ? slot?.error ?? 'Verifying server health'
|
||||
: (row.players ?? 0) > 0 ? `${row.players} players at the last check`
|
||||
: 'Waiting for restart eligibility and available capacity'),
|
||||
href: `/campaigns/${encodeURIComponent(row.id)}` });
|
||||
}
|
||||
// Only the newest lifecycle operation can still require action on a server.
|
||||
const lifecycle = handle.prepare(`SELECT d.* FROM server_deployments d
|
||||
WHERE d.state IN ('queued', 'running', 'waiting', 'awaiting-health')
|
||||
OR (d.state IN ('done', 'warning') AND d.finished_at >= ?)
|
||||
OR (d.state IN ('failed', 'interrupted', 'warning') AND NOT EXISTS
|
||||
(SELECT 1 FROM server_deployments n WHERE n.server = d.server AND n.rowid > d.rowid))`)
|
||||
.all(since) as { id: string; server: string; action: string; state: string; stage: string;
|
||||
error: string | null; started_at: string }[];
|
||||
for (const row of lifecycle) {
|
||||
const state = row.state === 'done' || row.state === 'warning' ? 'done'
|
||||
: ['failed', 'interrupted'].includes(row.state) ? 'attention' : 'active';
|
||||
operations.push({ key: `deployment:${row.id}`, group: `deployment:${row.id}`, kind: 'deployment',
|
||||
server: row.server, state, startedAt: row.started_at,
|
||||
label: state === 'done' ? row.state === 'warning' ? 'Completed with warnings' : 'Complete'
|
||||
: state === 'attention' ? `${row.action === 'create' ? 'Launch' : 'Removal'} needs attention` : row.stage,
|
||||
detail: row.error ?? row.stage, href: `/deployments/${encodeURIComponent(row.id)}` });
|
||||
}
|
||||
const betas = handle.prepare(`SELECT b.id, b.opened_at, bs.server, bs.stage, bs.error
|
||||
FROM betas b JOIN beta_servers bs ON bs.beta_id = b.id WHERE b.state IN ('running', 'ready-promote')`)
|
||||
.all() as { id: number; opened_at: string; server: string; stage: string; error: string | null }[];
|
||||
const stages: Record<string, string> = { queued: 'Waiting for an earlier test wave', deploying: 'Deploying beta',
|
||||
'awaiting-empty': 'Waiting for an empty lobby', restarting: 'Restarting', 'awaiting-health': 'Recovering',
|
||||
'awaiting-match': 'Observing a match', ready: 'Ready for review', failed: 'Beta needs attention' };
|
||||
for (const row of betas) {
|
||||
if (['restarting', 'awaiting-health'].includes(row.stage)) representedRecovery.add(row.server);
|
||||
operations.push({ key: `beta:${row.id}:${row.server}`, group: `beta:${row.id}`,
|
||||
kind: 'beta', server: row.server, startedAt: row.opened_at,
|
||||
state: row.stage === 'failed' ? 'attention' : 'active', label: stages[row.stage] ?? row.stage,
|
||||
detail: row.error ?? recovering.get(row.server)?.error ?? `Beta ${row.id}`, href: `/betas/${row.id}` });
|
||||
}
|
||||
for (const slot of slots) {
|
||||
if (representedRecovery.has(slot.server)) continue;
|
||||
operations.push({ key: `recovery:${slot.server}:${slot.requested_at}`, group: `recovery:${slot.server}`,
|
||||
kind: 'recovery', server: slot.server, startedAt: slot.requested_at, state: slot.error ? 'attention' : 'active',
|
||||
label: 'Recovering from restart', detail: slot.error ?? 'Verifying server health',
|
||||
href: `/servers/${encodeURIComponent(slot.server)}` });
|
||||
}
|
||||
return operations.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
|
||||
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
|
||||
|
||||
let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', onblueprint }:
|
||||
{ open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: string; onblueprint?: () => void } = $props();
|
||||
let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '', onblueprint }:
|
||||
{ open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string; onblueprint?: () => void } = $props();
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let game = $state<ZombieGame>('t5');
|
||||
let mapSlug = $state('');
|
||||
@@ -40,6 +40,8 @@
|
||||
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false;
|
||||
blueprintName = ''; description = ''; savedName = ''; saving = false;
|
||||
blueprintId = initialBlueprintId;
|
||||
game = initialGame;
|
||||
mapSlug = initialMapSlug;
|
||||
try {
|
||||
const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]);
|
||||
if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and blueprints. Try again.');
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
||||
import { elapsedSince } from '$ui/duration.js';
|
||||
let { name }: { name: string } = $props();
|
||||
let { name, since = null }: { name: string; since?: string | null } = $props();
|
||||
let report = $derived($telemetryByServer.get(name));
|
||||
let fresh = $derived(report && $uiNow - Date.parse(report.receivedAt) >= 0
|
||||
&& $uiNow - Date.parse(report.receivedAt) <= 120_000
|
||||
&& (!since || Date.parse(report.receivedAt) >= Date.parse(since)));
|
||||
</script>
|
||||
|
||||
{#if report}
|
||||
{#if report && fresh}
|
||||
<div class="round" aria-live="polite">
|
||||
<strong>Round {report.round}</strong>
|
||||
<span title={report.receivedAt}>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago · {report.players} players</span>
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
inactiveSince?: string | null;
|
||||
gameStartedAt?: string | null;
|
||||
gameEndedAt?: string | null;
|
||||
compactEmpty?: boolean;
|
||||
mapLabelOverride?: string;
|
||||
}
|
||||
let {
|
||||
name,
|
||||
@@ -46,7 +48,9 @@
|
||||
activeSince = null,
|
||||
inactiveSince = null,
|
||||
gameStartedAt = null,
|
||||
gameEndedAt = null
|
||||
gameEndedAt = null,
|
||||
compactEmpty = false,
|
||||
mapLabelOverride
|
||||
}: Props = $props();
|
||||
|
||||
function ago(iso: string | null): string {
|
||||
@@ -66,7 +70,7 @@
|
||||
);
|
||||
let showRoster = $derived(health === 'online' || health === 'stale');
|
||||
let title = $derived(serverTitle(name));
|
||||
let mapLabel = $derived(mapTitle(map));
|
||||
let mapLabel = $derived(mapLabelOverride ?? mapTitle(map));
|
||||
let gtLabel = $derived(gametypeLabel(gametype));
|
||||
|
||||
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
|
||||
@@ -106,7 +110,7 @@
|
||||
{:else}
|
||||
<div class="players" aria-live="polite">
|
||||
<Stat
|
||||
value={players ?? 0}
|
||||
value={players ?? '—'}
|
||||
size="md"
|
||||
tone={players !== null && players > 0 ? 'online' : 'primary'}
|
||||
tween
|
||||
@@ -123,9 +127,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showRoster}<RoundTelemetry {name} />{/if}
|
||||
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} since={gameStartedAt} />{/if}
|
||||
|
||||
{#if showTiming}
|
||||
{#if showTiming && !(compactEmpty && players === 0)}
|
||||
<div class="timing">
|
||||
{#if activeElapsed}
|
||||
<div class="metric">
|
||||
@@ -159,7 +163,7 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div class="lobby-empty">Lobby is empty</div>
|
||||
<div class="lobby-empty">{health === 'stale' ? 'Waiting for a fresh report' : players === 0 ? 'Lobby is empty' : 'Player roster unavailable'}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { toasts, dismiss } from '$ui/toast.js';
|
||||
</script>
|
||||
|
||||
<div class="toaster" role="region" aria-label="Notifications">
|
||||
<div class="toaster" role="region" aria-label="Notifications" aria-live="polite">
|
||||
{#each $toasts as t (t.id)}
|
||||
<div
|
||||
class="toast v-{t.variant}"
|
||||
@@ -13,6 +13,7 @@
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<span class="msg">{t.message}</span>
|
||||
{#if t.href}<a href={t.href} onclick={() => dismiss(t.id)}>View</a>{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="close"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export interface ServerStatus {
|
||||
}
|
||||
|
||||
export const statusByServer = writable<Map<string, ServerStatus>>(new Map());
|
||||
export const statusReceivedAt = writable(0);
|
||||
|
||||
// Shared 1s ticker for elapsed-time displays. One interval feeds every card
|
||||
// on the page instead of N per-card timers. Timer starts on the first
|
||||
@@ -67,6 +68,7 @@ export function startServerStream(): void {
|
||||
for (const e of data.entries) m.set(e.server, e);
|
||||
return m;
|
||||
});
|
||||
statusReceivedAt.set(Date.now());
|
||||
});
|
||||
if (tickInterval === null) {
|
||||
tickInterval = setInterval(() => _uiNow.set(Date.now()), 1000);
|
||||
|
||||
+4
-2
@@ -14,6 +14,7 @@ export interface Toast {
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
ttl: number;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export const toasts = writable<Toast[]>([]);
|
||||
@@ -22,13 +23,14 @@ let nextId = 1;
|
||||
|
||||
export function toast(
|
||||
message: string,
|
||||
opts: { variant?: ToastVariant; ttl?: number } = {}
|
||||
opts: { variant?: ToastVariant; ttl?: number; href?: string } = {}
|
||||
): void {
|
||||
const t: Toast = {
|
||||
id: nextId++,
|
||||
message,
|
||||
variant: opts.variant ?? 'info',
|
||||
ttl: opts.ttl ?? 3500
|
||||
ttl: opts.ttl ?? 3500,
|
||||
href: opts.href
|
||||
};
|
||||
toasts.update((arr) => [...arr, t]);
|
||||
if (t.ttl > 0) {
|
||||
|
||||
Reference in New Issue
Block a user