feat(web): stale-while-revalidate caches to eliminate tab-switch flicker
Build / build (push) Failing after 4m51s

Sidebar tabs, Settings, and drill-in detail pages re-fetched on every
visit (loading=true + onMount), flashing an empty skeleton frame on each
navigation. Add an SWR cache layer so revisiting a view renders cached
data instantly while refreshing in the background.

- resourceCache.ts: single-value + keyed (per-id) SWR cache factories
- caches.ts: per-resource cache instances; resetAllCaches() on logout
- eventsSnapshot.ts: warm-seed snapshot for the SSE/paginated events page
- List/sidebar pages read $cache.value via $derived, refresh() on mount;
  mutations refresh the cache
- Settings forms seed once from settingsCache (edit-safe) and refetch
  after save (PUT /api/settings returns {status}, not the Settings object)
- Detail [id] pages warm-seed per id; apps/[id] seeds {workload,containers},
  resets non-seeded panels on warm nav, clears workload on 404, and
  invalidates its cache entry on delete

Deferred (still cold-fetch): triggers/[id] (webhook secret + multi-fetch
body gate), apps/new (create wizard).
This commit is contained in:
2026-06-08 15:39:25 +03:00
parent 6b45ed62bb
commit 192204a51c
31 changed files with 1139 additions and 493 deletions
+246
View File
@@ -0,0 +1,246 @@
/**
* Module-scoped resource caches for sidebar list pages.
*
* Each cache is a stale-while-revalidate store (see `resourceCache.ts`). Pages
* read the value via `$store` and call `.refresh()` in `onMount` (and after
* mutations). Because these instances live at module scope, their data
* survives navigation, so revisiting a tab renders instantly instead of
* flashing a skeleton frame.
*
* Reset them all on logout so a different user never sees the previous
* session's cached data.
*/
import * as api from '$lib/api';
import type {
RedeployTrigger,
EventTrigger,
MetricAlertRule,
LogScanRule,
LogScanStats,
SharedSecret,
AuthSettings,
AuthUser
} from '$lib/api';
import type {
Workload,
Container,
ContainerView,
ProxyRoute,
App,
StaleContainer,
Settings,
Registry,
BackupInfo,
DnsRecordView
} from '$lib/types';
import {
createResourceCache,
createKeyedResourceCache,
type ResourceCache,
type KeyedResourceCache
} from './resourceCache';
import { clearEventsSnapshot } from './eventsSnapshot';
// ── Single-resource caches ──────────────────────────────────────────────
export const workloadsCache = createResourceCache<Workload[]>(
(signal) => api.listWorkloads(undefined, signal),
[]
);
export const containersCache = createResourceCache<ContainerView[]>(
(signal) => api.listContainers({}, signal),
[]
);
export const proxyRoutesCache = createResourceCache<ProxyRoute[]>(
(signal) => api.listProxyRoutes(signal),
[]
);
export const triggersCache = createResourceCache<RedeployTrigger[]>(
(signal) => api.listTriggers(undefined, signal),
[]
);
export const eventTriggersCache = createResourceCache<EventTrigger[]>(
(signal) => api.listEventTriggers(signal),
[]
);
export const metricAlertRulesCache = createResourceCache<MetricAlertRule[]>(
() => api.listMetricAlertRules(),
[]
);
// ── Composite caches (pages that load several resources together) ─────────
export interface LogScanData {
rules: LogScanRule[];
stats: LogScanStats | null;
}
export const logScanCache = createResourceCache<LogScanData>(
async (signal) => {
const [rules, stats] = await Promise.all([
api.listLogScanRules(),
api.getLogScanStats(signal).catch(() => null)
]);
return { rules, stats };
},
{ rules: [], stats: null }
);
export interface SharedSecretsData {
secrets: SharedSecret[];
apps: App[];
}
export const sharedSecretsCache = createResourceCache<SharedSecretsData>(
async (signal) => {
// App load failure is non-fatal — app-scoped rows fall back to a
// truncated id when the name lookup misses.
const [secrets, apps] = await Promise.all([
api.listSharedSecrets(),
api.listApps(signal).catch(() => [] as App[])
]);
return { secrets, apps };
},
{ secrets: [], apps: [] }
);
export interface DashboardData {
workloads: Workload[];
containers: ContainerView[];
stale: StaleContainer[];
unusedImagesMB: number;
unusedImagesCount: number;
unusedImagesExceeded: boolean;
}
export const dashboardCache = createResourceCache<DashboardData>(
async (signal) => {
// Each list falls back to empty so a single slow daemon (e.g. Docker
// stats) does not blank the whole dashboard.
const [workloads, containers, stale] = await Promise.all([
api.listWorkloads(undefined, signal),
api.listContainers({}, signal).catch(() => [] as ContainerView[]),
api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[])
]);
let unusedImagesMB = 0;
let unusedImagesCount = 0;
let unusedImagesExceeded = false;
try {
const s = await api.getUnusedImageStats(signal);
unusedImagesMB = s.total_size_mb;
unusedImagesCount = s.count;
unusedImagesExceeded = s.exceeded;
} catch {
/* non-critical */
}
return { workloads, containers, stale, unusedImagesMB, unusedImagesCount, unusedImagesExceeded };
},
{ workloads: [], containers: [], stale: [], unusedImagesMB: 0, unusedImagesCount: 0, unusedImagesExceeded: false }
);
// Shared global Settings object. Backs both the settings sub-layout (which
// derives the proxy provider for its sub-nav) and the settings landing form.
// `value` is null until first load — consumers guard with `?.`.
export const settingsCache = createResourceCache<Settings | null>(
(signal) => api.getSettings(signal),
null
);
// Settings subpages with their own (non-/settings) data sources.
export const registriesCache = createResourceCache<Registry[]>(() => api.listRegistries(), []);
export const backupsCache = createResourceCache<BackupInfo[]>(() => api.listBackups(), []);
export const authSettingsCache = createResourceCache<AuthSettings | null>(
() => api.getAuthSettings(),
null
);
export const usersCache = createResourceCache<AuthUser[]>(
async () => (await api.listUsers()) ?? [],
[]
);
// ── Drill-in LIST pages (single-value SWR) ────────────────────────────────
export const staleContainersCache = createResourceCache<StaleContainer[]>(
(signal) => api.fetchStaleContainers(signal),
[]
);
export interface DnsData {
records: DnsRecordView[];
wildcardDns: boolean;
}
export const dnsCache = createResourceCache<DnsData>(
async (signal) => {
const [s, records] = await Promise.all([api.getSettings(signal), api.getDnsRecords()]);
return { records, wildcardDns: s.wildcard_dns ?? true };
},
{ records: [], wildcardDns: true }
);
// ── DETAIL pages (keyed by route param) ───────────────────────────────────
// Per-id caches so revisiting a detail (or navigating A→B→A) renders the
// cached entity instantly while revalidating. Detail-FORM pages seed their
// editable fields once per id from these (see the pages for the guard).
// apps/[id] warm-seeds BOTH the workload and its containers so the live-status
// badge (which reads containers.length) shows last-known state on revisit
// rather than flashing a wrong "not deployed" before the fetch resolves.
export interface WorkloadDetailSeed {
workload: Workload;
containers: Container[];
}
export const workloadDetailCache = createKeyedResourceCache<WorkloadDetailSeed>(
async (key, signal) => ({
workload: await api.getWorkload(key, signal),
containers: await api.listWorkloadContainers(key, signal)
})
);
export const triggerDetailCache = createKeyedResourceCache<RedeployTrigger>(
(key, signal) => api.getTrigger(key, signal)
);
export const eventTriggerDetailCache = createKeyedResourceCache<EventTrigger>(
(key, signal) => api.getEventTrigger(Number(key), signal)
);
export const logScanRuleDetailCache = createKeyedResourceCache<LogScanRule>(
(key, signal) => api.getLogScanRule(Number(key), signal)
);
export const metricAlertRuleDetailCache = createKeyedResourceCache<MetricAlertRule>(
(key, signal) => api.getMetricAlertRule(Number(key), signal)
);
export const sharedSecretDetailCache = createKeyedResourceCache<SharedSecret>(
(key, signal) => api.getSharedSecret(key, signal)
);
const keyedCaches: KeyedResourceCache<unknown>[] = [
workloadDetailCache,
triggerDetailCache,
eventTriggerDetailCache,
logScanRuleDetailCache,
metricAlertRuleDetailCache,
sharedSecretDetailCache
] as KeyedResourceCache<unknown>[];
/** Every cache, for bulk reset on logout. */
const allCaches: ResourceCache<unknown>[] = [
workloadsCache,
containersCache,
proxyRoutesCache,
triggersCache,
eventTriggersCache,
metricAlertRulesCache,
logScanCache,
sharedSecretsCache,
dashboardCache,
settingsCache,
registriesCache,
backupsCache,
authSettingsCache,
usersCache,
staleContainersCache,
dnsCache
] as ResourceCache<unknown>[];
export function resetAllCaches(): void {
for (const c of allCaches) c.reset();
for (const c of keyedCaches) c.reset();
clearEventsSnapshot();
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Warm-start snapshot for the Event Log page.
*
* The events page can't use the standard resource cache (`caches.ts`) because
* its list is paginated, mutated by live SSE pushes, and edited by deletes —
* far more than a fetch-and-replace list. Instead it keeps working in local
* component state and mirrors the latest list/stats here. On revisit the page
* seeds from this snapshot so it paints the last-known events immediately
* instead of flashing a skeleton, then refreshes in the background.
*
* Module-scoped (survives navigation, cleared on logout via `resetAllCaches`).
*/
import type { EventLogEntry, EventLogStats } from '$lib/types';
export interface EventsSnapshot {
events: EventLogEntry[];
stats: EventLogStats;
}
let snapshot: EventsSnapshot | null = null;
export function getEventsSnapshot(): EventsSnapshot | null {
return snapshot;
}
export function saveEventsSnapshot(s: EventsSnapshot): void {
snapshot = s;
}
export function clearEventsSnapshot(): void {
snapshot = null;
}
+209
View File
@@ -0,0 +1,209 @@
/**
* Stale-while-revalidate resource cache.
*
* Backs list pages so that switching between sidebar tabs doesn't flash an
* empty/skeleton frame on every visit. The first visit to a page does a cold
* load (skeleton shown once); every subsequent visit renders the previously
* loaded value immediately while a background refresh keeps it current.
*
* The cache is module-scoped (one instance per resource, created in
* `caches.ts`) so its value survives component unmount/remount across
* client-side navigation — that persistence is what removes the flicker.
*
* Style mirrors `navCounts.ts`: a classic `writable` exposed as a `Readable`
* plus imperative `refresh()`/`set()` methods, so pages keep using `$store`
* auto-subscription.
*/
import { writable, get, type Readable } from 'svelte/store';
export interface ResourceState<T> {
/** Last successfully loaded value (or the initial seed before first load). */
value: T;
/**
* True only during the *cold* load — i.e. the resource has never loaded
* successfully and there is no error yet. Drives the skeleton. Stays false
* for background refreshes so revisits never flash.
*/
loading: boolean;
/** True while any fetch (cold or background) is in flight. */
refreshing: boolean;
/** Last error message, or '' when the most recent fetch succeeded. */
error: string;
/** Whether at least one fetch has ever succeeded. */
loaded: boolean;
}
export interface ResourceCache<T> extends Readable<ResourceState<T>> {
/**
* (Re)fetch the resource. Aborts any in-flight fetch so the latest call
* wins — important after a mutation, where a stale background refresh must
* not clobber fresh data. Safe to call from `onMount` on every visit.
*/
refresh(): Promise<void>;
/** Optimistically replace the cached value (e.g. after a local mutation). */
set(value: T): void;
/** Clear back to the cold/initial state (e.g. on logout). */
reset(): void;
}
export function createResourceCache<T>(
fetcher: (signal?: AbortSignal) => Promise<T>,
initial: T
): ResourceCache<T> {
const seed: ResourceState<T> = {
value: initial,
loading: true,
refreshing: false,
error: '',
loaded: false
};
const store = writable<ResourceState<T>>(seed);
let inFlight: AbortController | null = null;
async function refresh(): Promise<void> {
// Latest-wins: cancel a previous in-flight fetch so a slow background
// refresh can't overwrite a newer (e.g. post-mutation) result.
if (inFlight) inFlight.abort();
const ac = new AbortController();
inFlight = ac;
store.update((s) => ({ ...s, refreshing: true, error: '' }));
try {
const value = await fetcher(ac.signal);
if (ac.signal.aborted) return;
store.set({ value, loading: false, refreshing: false, error: '', loaded: true });
} catch (e) {
if (ac.signal.aborted) return;
const error = e instanceof Error ? e.message : String(e);
// Keep the last good value (stale-while-revalidate) and surface the
// error; clear `loading` so the cold skeleton resolves to either data
// or an error state rather than spinning forever.
store.update((s) => ({ ...s, loading: false, refreshing: false, error }));
} finally {
if (inFlight === ac) inFlight = null;
}
}
function set(value: T): void {
store.update((s) => ({ ...s, value, loaded: true, loading: false }));
}
function reset(): void {
if (inFlight) {
inFlight.abort();
inFlight = null;
}
store.set({ ...seed, value: initial });
}
return { subscribe: store.subscribe, refresh, set, reset };
}
// ─────────────────────────────────────────────────────────────────────────
// Keyed (per-id) variant — for detail pages fetched by a route param.
//
// A single-value cache can't serve detail pages (each `[id]` is different
// data). This holds a Map<id, state> so revisiting (or A→B→A navigating) a
// detail renders the cached entity instantly while revalidating that id.
// Pages read reactively via `$cache.get(id)` (the store value is the Map) and
// call `cache.refresh(id)` / `cache.peek(id)` (methods on the object).
// ─────────────────────────────────────────────────────────────────────────
export interface KeyedResourceState<T> {
/** Last loaded value for this key, or null before its first load. */
value: T | null;
/** True on the cold load for this key (never loaded, no error). */
loading: boolean;
/** True while a fetch for this key is in flight. */
refreshing: boolean;
error: string;
loaded: boolean;
}
function coldKeyed<T>(): KeyedResourceState<T> {
return { value: null, loading: true, refreshing: false, error: '', loaded: false };
}
export interface KeyedResourceCache<T> extends Readable<Map<string, KeyedResourceState<T>>> {
/** (Re)fetch one key; aborts a prior in-flight fetch for the same key. */
refresh(key: string): Promise<void>;
/** Optimistically replace one key's value (e.g. after a mutation). */
set(key: string, value: T): void;
/** Non-reactive snapshot read; returns the cold state if the key is absent. */
peek(key: string): KeyedResourceState<T>;
/** Drop a single key (e.g. after deleting that entity) so a later visit
* doesn't warm-seed a stale/phantom value. */
remove(key: string): void;
/** Drop all keys (e.g. on logout). */
reset(): void;
}
export function createKeyedResourceCache<T>(
fetcher: (key: string, signal?: AbortSignal) => Promise<T>
): KeyedResourceCache<T> {
const store = writable<Map<string, KeyedResourceState<T>>>(new Map());
const inFlight = new Map<string, AbortController>();
function patch(key: string, partial: Partial<KeyedResourceState<T>>): void {
store.update((m) => {
const next = new Map(m);
next.set(key, { ...(next.get(key) ?? coldKeyed<T>()), ...partial });
return next;
});
}
async function refresh(key: string): Promise<void> {
const prev = inFlight.get(key);
if (prev) prev.abort();
const ac = new AbortController();
inFlight.set(key, ac);
patch(key, { refreshing: true, error: '' });
try {
const value = await fetcher(key, ac.signal);
if (ac.signal.aborted) return;
patch(key, { value, loaded: true, loading: false, refreshing: false, error: '' });
} catch (e) {
if (ac.signal.aborted) return;
patch(key, {
loading: false,
refreshing: false,
error: e instanceof Error ? e.message : String(e)
});
} finally {
if (inFlight.get(key) === ac) inFlight.delete(key);
}
}
function set(key: string, value: T): void {
patch(key, { value, loaded: true, loading: false });
}
function peek(key: string): KeyedResourceState<T> {
return get(store).get(key) ?? coldKeyed<T>();
}
function remove(key: string): void {
const ac = inFlight.get(key);
if (ac) {
ac.abort();
inFlight.delete(key);
}
store.update((m) => {
if (!m.has(key)) return m;
const next = new Map(m);
next.delete(key);
return next;
});
}
function reset(): void {
for (const ac of inFlight.values()) ac.abort();
inFlight.clear();
store.set(new Map());
}
return { subscribe: store.subscribe, refresh, set, peek, remove, reset };
}