feat(web): stale-while-revalidate caches to eliminate tab-switch flicker
Build / build (push) Failing after 4m51s
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:
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user