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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user