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
+7 -15
View File
@@ -1,14 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as api from '$lib/api';
import type { MetricAlertRule } from '$lib/api';
import { metricAlertRulesCache } from '$lib/stores/caches';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { t } from '$lib/i18n';
let rules = $state<MetricAlertRule[]>([]);
let loading = $state(true);
let error = $state('');
// Cache-backed (stale-while-revalidate) so revisiting this tab renders the
// list immediately instead of flashing the cold skeleton. See caches.ts.
const rules = $derived<MetricAlertRule[]>($metricAlertRulesCache.value);
const loading = $derived($metricAlertRulesCache.loading);
const error = $derived($metricAlertRulesCache.error);
let filter = $state<'all' | 'global' | 'workload'>('all');
const globals = $derived(rules.filter((r) => r.workload_id === ''));
@@ -26,17 +28,7 @@
}
});
async function load(): Promise<void> {
loading = true;
error = '';
try {
rules = await api.listMetricAlertRules();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load metric alert rules';
} finally {
loading = false;
}
}
const load = () => metricAlertRulesCache.refresh();
function scopeLabel(r: MetricAlertRule): string {
if (r.workload_id !== '') {
@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import * as api from '$lib/api';
import type { MetricAlertRule, MetricAlertRuleInput } from '$lib/api';
import { metricAlertRuleDetailCache } from '$lib/stores/caches';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
@@ -51,42 +52,68 @@
: $t('metricalert.form.thresholdHintPercent')
);
function seedForm(r: MetricAlertRule): void {
name = r.name;
metric = r.metric;
comparator = r.comparator;
threshold = String(r.threshold);
severity = r.severity;
cooldownSeconds = r.cooldown_seconds;
enabled = r.enabled;
}
// Best-effort scope-label resolution (fire-and-forget); failure falls back
// to the truncated id. Runs once per id alongside the form seed.
async function resolveScopeName(r: MetricAlertRule, k: string): Promise<void> {
if (!r.workload_id) {
scopedWorkloadName = '';
return;
}
try {
const w = await api.getWorkload(r.workload_id);
// Ignore a late response if we've since navigated to another id.
if (String(id) === k) scopedWorkloadName = w.name;
} catch {
if (String(id) === k) scopedWorkloadName = '';
}
}
// Keyed warm-seed: render the cached rule instantly on revisit and seed the
// editable form ONCE per id (a background revalidation never re-seeds, so
// unsaved edits survive). Plain (non-reactive) so the $effect tracks only id.
let seededKey: string | null = null;
async function load(): Promise<void> {
if (id === null) {
error = 'Invalid rule id';
loading = false;
return;
}
loading = true;
error = '';
try {
const r = await api.getMetricAlertRule(id);
rule = r;
name = r.name;
metric = r.metric;
comparator = r.comparator;
threshold = String(r.threshold);
severity = r.severity;
cooldownSeconds = r.cooldown_seconds;
enabled = r.enabled;
// Best-effort: resolve the workload name for the scope
// label. Failure here doesn't block the rest of the page —
// scopeLabel falls back to the truncated id.
if (r.workload_id) {
try {
const w = await api.getWorkload(r.workload_id);
scopedWorkloadName = w.name;
} catch {
scopedWorkloadName = '';
}
} else {
scopedWorkloadName = '';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load rule';
} finally {
const k = String(id);
const cached = metricAlertRuleDetailCache.peek(k);
if (cached.value) {
rule = cached.value;
if (seededKey !== k) { seedForm(cached.value); resolveScopeName(cached.value, k); seededKey = k; }
loading = false;
} else {
loading = true;
}
error = '';
await metricAlertRuleDetailCache.refresh(k);
// Bail if the route id changed while we were fetching — a newer load()
// now owns the page state; applying this stale result would clobber it.
if (id === null || String(id) !== k) return;
const entry = metricAlertRuleDetailCache.peek(k);
if (entry.error && !cached.value) {
error = entry.error || 'Failed to load rule';
loading = false;
return;
}
if (entry.value) {
rule = entry.value;
if (seededKey !== k) { seedForm(entry.value); resolveScopeName(entry.value, k); seededKey = k; }
}
loading = false;
}
async function save(e?: Event): Promise<void> {
@@ -105,6 +132,7 @@
enabled
};
rule = await api.updateMetricAlertRule(id, body);
metricAlertRuleDetailCache.set(String(id), rule);
} catch (e) {
error = e instanceof Error ? e.message : 'Save failed';
} finally {