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 };
}
+2
View File
@@ -13,6 +13,7 @@
import { logout as apiLogout } from '$lib/api';
import { t } from '$lib/i18n';
import { navCounts, startNavCountsPolling, stopNavCountsPolling, refreshNavCounts } from '$lib/stores/navCounts';
import { resetAllCaches } from '$lib/stores/caches';
import { health, startHealthPolling, stopHealthPolling, refreshHealth } from '$lib/stores/health';
import { effectiveTimezone, formatOffsetLabel } from '$lib/stores/timezone';
import { fmt } from '$lib/format/datetime';
@@ -357,6 +358,7 @@
onclick={async () => {
try { await apiLogout(); } catch { /* best effort */ }
clearAuth();
resetAllCaches();
goto('/login');
}}
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
+15 -46
View File
@@ -6,8 +6,7 @@
// We no longer fan out N+1 fetches per project to gather instance
// status — the global containers index already carries the workload
// reference and state.
import type { ContainerView, StaleContainer, Workload } from '$lib/types';
import * as api from '$lib/api';
import { dashboardCache } from '$lib/stores/caches';
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
@@ -20,54 +19,24 @@
import { t } from '$lib/i18n';
import { fmt } from '$lib/format/datetime';
let workloads = $state<Workload[]>([]);
let containers = $state<ContainerView[]>([]);
let staleContainers = $state<StaleContainer[]>([]);
let unusedImagesMB = $state(0);
let unusedImagesCount = $state(0);
let unusedImagesExceeded = $state(false);
let loading = $state(true);
let error = $state('');
let loadController: AbortController | null = null;
// Cache-backed (stale-while-revalidate) so switching back to the dashboard
// renders the last-known figures immediately instead of flashing skeleton
// cards. The composite cache fans out the same parallel reads (each with an
// empty fallback) and aborts a prior in-flight refresh on the next call.
// See caches.ts.
const workloads = $derived($dashboardCache.value.workloads);
const containers = $derived($dashboardCache.value.containers);
const staleContainers = $derived($dashboardCache.value.stale);
const unusedImagesMB = $derived($dashboardCache.value.unusedImagesMB);
const unusedImagesCount = $derived($dashboardCache.value.unusedImagesCount);
const unusedImagesExceeded = $derived($dashboardCache.value.unusedImagesExceeded);
const loading = $derived($dashboardCache.loading);
const error = $derived($dashboardCache.error);
async function loadDashboard() {
loadController?.abort();
const controller = new AbortController();
loadController = controller;
const signal = controller.signal;
loading = true;
error = '';
try {
// Parallelize the cheap top-level reads. Each falls back to an
// empty list so a single slow daemon (e.g. Docker stats) does
// not blank the entire dashboard.
const [wls, ctrs, stale] = await Promise.all([
api.listWorkloads(undefined, signal),
api.listContainers({}, signal).catch(() => [] as ContainerView[]),
api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[])
]);
workloads = wls;
containers = ctrs;
staleContainers = stale;
try {
const imgStats = await api.getUnusedImageStats(signal);
unusedImagesMB = imgStats.total_size_mb;
unusedImagesCount = imgStats.count;
unusedImagesExceeded = imgStats.exceeded;
} catch { /* non-critical */ }
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : $t('dashboard.loadFailed');
} finally {
loading = false;
}
}
const loadDashboard = () => dashboardCache.refresh();
$effect(() => {
loadDashboard();
return () => { loadController?.abort(); };
});
// Plugin-native workloads only. Legacy pre-cutover rows (kind project/
+9 -16
View File
@@ -1,14 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Workload } from '$lib/types';
import * as api from '$lib/api';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { workloadsCache } from '$lib/stores/caches';
import { t } from '$lib/i18n';
let workloads = $state<Workload[]>([]);
let loading = $state(true);
let error = $state('');
// Cache-backed (stale-while-revalidate): the value persists across
// navigation, so revisiting this tab renders the table immediately instead
// of flashing the cold skeleton. The skeleton only shows on the very first
// visit of the session.
const workloads = $derived($workloadsCache.value);
const loading = $derived($workloadsCache.loading);
const error = $derived($workloadsCache.error);
let filter = $state<'all' | string>('all');
// Plugin-native rows are those with a source_kind. trigger_kind is no
@@ -27,17 +30,7 @@
pluginRows.filter((w) => w.source_kind === kind).length
);
async function load() {
loading = true;
error = '';
try {
workloads = await api.listWorkloads();
} catch (e) {
error = e instanceof Error ? e.message : $t('apps.list.loadError');
} finally {
loading = false;
}
}
const load = () => workloadsCache.refresh();
function sourceBadge(kind: string): string {
switch (kind) {
+52 -5
View File
@@ -2,6 +2,7 @@
import { onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { get } from 'svelte/store';
import type { Container, EventLogEntry, PluginWorkloadInput, Workload } from '$lib/types';
import type { RedeployTrigger, WorkloadTriggerBinding } from '$lib/api';
import * as api from '$lib/api';
@@ -65,15 +66,26 @@
import { fmt } from '$lib/format/datetime';
import { formatBytes } from '$lib/format/bytes';
import { connectGlobalEvents, toEventLogEntry, type SSEConnection } from '$lib/sse';
import { workloadDetailCache } from '$lib/stores/caches';
// Route params come back as `string | undefined`; the route file
// guarantees `id` exists, but the empty-string fallback satisfies
// the type checker — server validation rejects empty ids anyway.
const id = $derived($page.params.id ?? '');
let workload = $state<Workload | null>(null);
let containers = $state<Container[]>([]);
let loading = $state(true);
// Warm-seed the workload from the per-id cache so the body — gated on
// `loading && !workload` — skips the full-page skeleton on revisit (the
// other sections populate when load() resolves). Seeding synchronously at
// init (not just in the load effect) avoids even a one-frame skeleton on a
// fresh mount. Edit-mode forms seed on-demand from `workload`, so there is
// no on-load form state to clobber.
const _seed = workloadDetailCache.peek(get(page).params.id ?? '').value;
let workload = $state<Workload | null>(_seed?.workload ?? null);
// Containers are seeded too (not just the workload) so the live-status badge
// + Stop/Start toolbar reflect last-known state on a warm revisit instead of
// momentarily reading the empty array as "not deployed".
let containers = $state<Container[]>(_seed?.containers ?? []);
let loading = $state(_seed == null);
let error = $state('');
let deploying = $state(false);
let deployRef = $state('');
@@ -903,7 +915,28 @@
);
async function load() {
loading = true;
const k = id;
// Reused-component nav (A→B): warm-seed B's header instantly if cached;
// a cold id keeps the skeleton until the fetch resolves.
const cached = workloadDetailCache.peek(k);
if (cached.value) {
workload = cached.value.workload;
containers = cached.value.containers;
// Reset the non-seeded dependent panels so a reused-component warm
// nav (A→B) never renders the previous id's chain / bindings / rules
// / env / volumes under the new id's header; they repopulate when
// this id's Promise.all below resolves.
chain = null;
bindings = [];
logRules = [];
envRows = [];
volumeRows = [];
chainError = '';
logRulesError = '';
bindingsError = '';
} else {
loading = true;
}
error = '';
try {
const [w, c, env, vols, ch, lr, bs] = await Promise.all([
@@ -924,8 +957,12 @@
return [] as WorkloadTriggerBinding[];
})
]);
// Bail if the route id changed mid-flight — a newer load() owns the
// page state and applying this stale result would clobber it.
if (id !== k) return;
workload = w;
containers = c;
workloadDetailCache.set(k, { workload: w, containers: c });
envRows = env;
volumeRows = vols;
chain = ch;
@@ -970,9 +1007,16 @@
// session storage may be disabled — ignore.
}
} catch (e) {
if (id !== k) return;
// Clear the (possibly warm-seeded) workload so a 404 / failed load
// resolves to the clean error page instead of a phantom, interactive
// detail UI for an entity that no longer exists.
workload = null;
error = e instanceof Error ? e.message : $t('apps.detail.loadError');
} finally {
loading = false;
// Only the current id's load may clear the skeleton — a stale load
// returning early must not flip a newer load's loading state.
if (id === k) loading = false;
}
}
@@ -1391,6 +1435,9 @@
error = '';
try {
await api.deletePluginWorkload(id);
// Drop the cached entry so navigating back to this (now-deleted) id
// doesn't warm-seed a phantom detail page.
workloadDetailCache.remove(id);
goto('/apps');
} catch (e) {
error = e instanceof Error ? e.message : $t('apps.detail.deleteError');
+13 -23
View File
@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { ContainerView, WorkloadKind } from '$lib/types';
import * as api from '$lib/api';
import { containersCache } from '$lib/stores/caches';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
@@ -14,10 +14,15 @@
// allContainers holds the unfiltered list — kind/state filters are applied
// client-side so the tab counters reflect the whole population, not the
// current narrowed view (otherwise picking "Project" would show All=0).
let allContainers = $state<ContainerView[]>([]);
let loading = $state(true);
let refreshing = $state(false);
let error = $state('');
//
// Cache-backed (stale-while-revalidate): the list persists across
// navigation, so revisiting this tab renders immediately instead of
// flashing the cold skeleton. The cache's abort-on-refresh replaces the
// previous manual `loadSeq` race guard.
const allContainers = $derived($containersCache.value);
const loading = $derived($containersCache.loading);
const refreshing = $derived($containersCache.refreshing);
const error = $derived($containersCache.error);
// Filters seed from query string so the tab is shareable / refresh-safe.
const initialKind = (() => {
@@ -32,24 +37,9 @@
let stateFilter = $state(initialState);
let searchTerm = $state(initialQ);
async function load(initial: boolean): Promise<void> {
if (initial) loading = true;
else refreshing = true;
error = '';
try {
// Race-safety: keep the latest fetch's result and discard stragglers.
const seq = ++loadSeq;
const containers = await api.listContainers({});
if (seq !== loadSeq) return;
allContainers = containers;
} catch (e) {
error = e instanceof Error ? e.message : $t('containers.errLoad');
} finally {
loading = false;
refreshing = false;
}
}
let loadSeq = 0;
// `initial` is retained for call-site compatibility; the cache distinguishes
// the cold load (skeleton) from background refreshes on its own.
const load = (_initial = false): Promise<void> => containersCache.refresh();
$effect(() => {
void load(true);
+10 -29
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { StaleContainer } from '$lib/types';
import * as api from '$lib/api';
import { staleContainersCache } from '$lib/stores/caches';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
@@ -10,37 +10,19 @@
import { t } from '$lib/i18n';
import { fmt } from '$lib/format/datetime';
let containers = $state<StaleContainer[]>([]);
let loading = $state(true);
let error = $state('');
// Cache-backed (stale-while-revalidate) so revisiting renders instantly
// instead of flashing the cold skeleton; the cache aborts a prior in-flight
// fetch on the next refresh (replacing the old manual loadController guard).
const containers = $derived($staleContainersCache.value);
const loading = $derived($staleContainersCache.loading);
const error = $derived($staleContainersCache.error);
let confirmSingleId = $state('');
let confirmBulk = $state(false);
let cleaningIds = $state<Set<string>>(new Set());
let bulkCleaning = $state(false);
let loadController: AbortController | null = null;
async function loadStale() {
loadController?.abort();
const ac = new AbortController();
loadController = ac;
loading = true;
error = '';
try {
const rows = await api.fetchStaleContainers(ac.signal);
if (ac.signal.aborted) return;
containers = rows;
} catch (e) {
if (ac.signal.aborted) return;
error = e instanceof Error ? e.message : $t('stale.loadFailed');
} finally {
if (loadController === ac) {
loading = false;
loadController = null;
}
}
}
const loadStale = () => staleContainersCache.refresh();
function requestCleanup(id: string) {
confirmSingleId = id;
@@ -52,7 +34,7 @@
cleaningIds = new Set([...cleaningIds, id]);
try {
await api.cleanupStaleContainer(id);
containers = containers.filter((c) => c.container.id !== id);
await staleContainersCache.refresh();
toasts.success($t('stale.cleanedUp'));
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('stale.cleanupFailed'));
@@ -68,7 +50,7 @@
bulkCleaning = true;
try {
const result = await api.bulkCleanupStaleContainers();
containers = [];
await staleContainersCache.refresh();
toasts.success($t('stale.bulkCleanedUp', { count: String(result.deleted) }));
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('stale.cleanupFailed'));
@@ -79,7 +61,6 @@
$effect(() => {
loadStale();
return () => loadController?.abort();
});
</script>
+12 -16
View File
@@ -1,15 +1,18 @@
<script lang="ts">
import { getDnsRecords, syncDnsRecords, deleteDnsRecord, getSettings } from '$lib/api';
import type { DnsRecordView } from '$lib/types';
import { get } from 'svelte/store';
import { syncDnsRecords, deleteDnsRecord } from '$lib/api';
import { dnsCache } from '$lib/stores/caches';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconSearch, IconRefresh, IconTrash, IconLoader } from '$lib/components/icons';
import Skeleton from '$lib/components/Skeleton.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
let loading = $state(true);
let records = $state<DnsRecordView[]>([]);
let wildcardDns = $state(true);
// Cache-backed (stale-while-revalidate) so revisiting renders records
// instantly instead of flashing the cold skeleton. See caches.ts.
const records = $derived($dnsCache.value.records);
const wildcardDns = $derived($dnsCache.value.wildcardDns);
const loading = $derived($dnsCache.loading);
let syncing = $state(false);
// Filters
@@ -37,16 +40,9 @@
});
async function loadRecords() {
loading = true;
try {
const settings = await getSettings();
wildcardDns = settings.wildcard_dns ?? true;
records = await getDnsRecords();
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('dns.loadFailed'));
} finally {
loading = false;
}
await dnsCache.refresh();
const { error } = get(dnsCache);
if (error) toasts.error(error || $t('dns.loadFailed'));
}
async function handleSync() {
@@ -66,7 +62,7 @@
try {
await deleteDnsRecord(fqdn);
toasts.success($t('dns.recordDeleted', { fqdn }));
records = records.filter(r => r.fqdn !== fqdn);
await dnsCache.refresh();
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('dns.deleteFailed'));
}
+7 -15
View File
@@ -1,29 +1,21 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as api from '$lib/api';
import type { EventTrigger } from '$lib/api';
import { eventTriggersCache } from '$lib/stores/caches';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { t } from '$lib/i18n';
let triggers = $state<EventTrigger[]>([]);
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 triggers = $derived<EventTrigger[]>($eventTriggersCache.value);
const loading = $derived($eventTriggersCache.loading);
const error = $derived($eventTriggersCache.error);
const enabledCount = $derived(triggers.filter((t) => t.enabled).length);
const disabledCount = $derived(triggers.length - enabledCount);
async function load(): Promise<void> {
loading = true;
error = '';
try {
triggers = await api.listEventTriggers();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load event triggers';
} finally {
loading = false;
}
}
const load = () => eventTriggersCache.refresh();
// Render a short, comma-separated filter summary so the list table can
// fit each trigger in a single row without a sub-table. The
+47 -19
View File
@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import * as api from '$lib/api';
import type { EventTrigger, EventTriggerInput, NotificationTestResult } from '$lib/api';
import { eventTriggerDetailCache } 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,33 +52,57 @@
}
});
function seedForm(tr: EventTrigger): void {
name = tr.name;
filterSeverity = tr.filter_severity;
filterSource = tr.filter_source;
filterMessageRegex = tr.filter_message_regex;
actionTarget = tr.action_target;
// Server returns either "" (no secret) or a placeholder when one is
// configured. Keep the value so an unchanged echo round-trips as "no
// change."
actionSecret = tr.action_secret;
secretConfigured = tr.action_secret !== '';
enabled = tr.enabled;
}
// Keyed warm-seed: on revisit, render the cached trigger instantly and seed
// the editable form ONCE per id (a background revalidation never re-seeds,
// so unsaved edits survive). `seededKey` is intentionally a plain (non-
// reactive) var so the $effect below depends only on `id`.
let seededKey: string | null = null;
async function load(): Promise<void> {
if (id === null) {
error = 'Invalid trigger id';
loading = false;
return;
}
loading = true;
error = '';
try {
const tr = await api.getEventTrigger(id);
trigger = tr;
name = tr.name;
filterSeverity = tr.filter_severity;
filterSource = tr.filter_source;
filterMessageRegex = tr.filter_message_regex;
actionTarget = tr.action_target;
// Server returns either "" (no secret) or a placeholder
// when one is configured. Keep the value in state so an
// unchanged echo round-trips as "no change."
actionSecret = tr.action_secret;
secretConfigured = tr.action_secret !== '';
enabled = tr.enabled;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load trigger';
} finally {
const k = String(id);
const cached = eventTriggerDetailCache.peek(k);
if (cached.value) {
trigger = cached.value;
if (seededKey !== k) { seedForm(cached.value); seededKey = k; }
loading = false;
} else {
loading = true;
}
error = '';
await eventTriggerDetailCache.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 = eventTriggerDetailCache.peek(k);
if (entry.error && !cached.value) {
error = entry.error || 'Failed to load trigger';
loading = false;
return;
}
if (entry.value) {
trigger = entry.value;
if (seededKey !== k) { seedForm(entry.value); seededKey = k; }
}
loading = false;
}
async function save(e?: Event): Promise<void> {
@@ -99,6 +124,9 @@
trigger = await api.updateEventTrigger(id, body);
actionSecret = trigger.action_secret;
secretConfigured = trigger.action_secret !== '';
// Keep the cache warm-fresh for the next visit (doesn't touch the
// form — seededKey already matches this id).
eventTriggerDetailCache.set(String(id), trigger);
} catch (e) {
error = e instanceof Error ? e.message : 'Save failed';
} finally {
+19 -4
View File
@@ -10,6 +10,7 @@
import { toasts } from '$lib/stores/toast';
import { connectGlobalEvents, toEventLogEntry, type SSEConnection, type EventLogSSEPayload } from '$lib/sse';
import type { EventLogEntry, EventLogStats } from '$lib/types';
import { getEventsSnapshot, saveEventsSnapshot } from '$lib/stores/eventsSnapshot';
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
import EventLogFilter from '$lib/components/EventLogFilter.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
@@ -18,9 +19,13 @@
// ── State ─────────────────────────────────────────────────────
let events = $state<EventLogEntry[]>([]);
let stats = $state<EventLogStats>({ info: 0, warn: 0, error: 0, total: 0 });
let loading = $state(true);
// Warm-start from the last-known snapshot so switching back to this tab
// paints the previous events immediately instead of a skeleton; a
// background refresh in onMount then brings it current. See eventsSnapshot.ts.
const seed = getEventsSnapshot();
let events = $state<EventLogEntry[]>(seed?.events ?? []);
let stats = $state<EventLogStats>(seed?.stats ?? { info: 0, warn: 0, error: 0, total: 0 });
let loading = $state((seed?.events.length ?? 0) === 0);
let loadingMore = $state(false);
let hasMore = $state(true);
let newEventIds = $state<Set<number>>(new Set());
@@ -61,7 +66,10 @@
const currentOffset = append ? offset : 0;
if (append) {
loadingMore = true;
} else {
} else if (events.length === 0) {
// Only show the skeleton on a true cold load. When we already have
// (seeded or previously loaded) events, refresh in place so the list
// never blanks out — this is what removes the tab-switch flicker.
loading = true;
}
@@ -135,6 +143,13 @@
loadEvents();
}
// Mirror the current list + stats into the module snapshot so the next
// visit can warm-start. Cheap (stores references); keeps the snapshot fresh
// through loads, SSE pushes, and deletes without touching each mutation site.
$effect(() => {
saveEventsSnapshot({ events, stats });
});
// ── Client-side text filter ──────────────────────────────────
const filteredEvents = $derived(
+13 -27
View File
@@ -1,21 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as api from '$lib/api';
import type { LogScanRule, LogScanStats } from '$lib/api';
import type { LogScanRule } from '$lib/api';
import { logScanCache } 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<LogScanRule[]>([]);
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.
// Scanner stats are loaded alongside the rule list (non-fatal) so the
// operator sees drop counters + compile errors next to the rules causing
// them. Named `scanStats` to avoid colliding with the `{#snippet stats()}`
// slot below that feeds the hero.
const rules = $derived<LogScanRule[]>($logScanCache.value.rules);
const scanStats = $derived($logScanCache.value.stats);
const loading = $derived($logScanCache.loading);
const error = $derived($logScanCache.error);
let filter = $state<'all' | 'global' | 'workload' | 'override'>('all');
// Scanner stats are loaded alongside the rule list so the
// operator sees drop counters + compile errors next to the rules
// causing them. Failure to load is non-fatal — the rules table
// is the primary content. Named `scanStats` to avoid colliding
// with the `{#snippet stats()}` slot below that feeds the hero.
let scanStats = $state<LogScanStats | null>(null);
const globals = $derived(rules.filter((r) => r.workload_id === '' && r.overrides_id === 0));
const workloadOnly = $derived(
@@ -37,22 +38,7 @@
}
});
async function load(): Promise<void> {
loading = true;
error = '';
try {
const [rs, st] = await Promise.all([
api.listLogScanRules(),
api.getLogScanStats().catch(() => null)
]);
rules = rs;
scanStats = st;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load log scan rules';
} finally {
loading = false;
}
}
const load = () => logScanCache.refresh();
function scopeLabel(r: LogScanRule): string {
if (r.overrides_id !== 0) {
+55 -27
View File
@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import * as api from '$lib/api';
import type { LogScanRule, LogScanRuleInput, LogScanTestResult } from '$lib/api';
import { logScanRuleDetailCache } 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';
@@ -49,41 +50,67 @@
}
});
function seedForm(r: LogScanRule): void {
name = r.name;
pattern = r.pattern;
severity = r.severity;
streams = r.streams;
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: LogScanRule, 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.getLogScanRule(id);
rule = r;
name = r.name;
pattern = r.pattern;
severity = r.severity;
streams = r.streams;
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 = logScanRuleDetailCache.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 logScanRuleDetailCache.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 = logScanRuleDetailCache.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> {
@@ -101,6 +128,7 @@
enabled
};
rule = await api.updateLogScanRule(id, body);
logScanRuleDetailCache.set(String(id), rule);
} catch (e) {
error = e instanceof Error ? e.message : 'Save failed';
} finally {
+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 {
+11 -11
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { listProxyRoutes } from '$lib/api';
import { get } from 'svelte/store';
import type { ProxyRoute, ProxyRouteSource } from '$lib/types';
import { proxyRoutesCache } from '$lib/stores/caches';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import Skeleton from '$lib/components/Skeleton.svelte';
@@ -10,8 +11,10 @@
type SourceFilter = 'all' | ProxyRouteSource;
let routes = $state<ProxyRoute[]>([]);
let loading = $state(true);
// Cache-backed (stale-while-revalidate) so revisiting this tab renders the
// routes immediately instead of flashing the cold skeleton. See caches.ts.
const routes = $derived<ProxyRoute[]>($proxyRoutesCache.value);
const loading = $derived($proxyRoutesCache.loading);
let search = $state('');
let sourceFilter = $state<SourceFilter>('all');
@@ -66,14 +69,11 @@
}
async function loadRoutes() {
loading = true;
try {
routes = await listProxyRoutes();
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('proxies.loadFailed'));
} finally {
loading = false;
}
await proxyRoutesCache.refresh();
// Cache stores the error rather than throwing; surface it as a toast to
// preserve the page's prior failure UX.
const { error } = get(proxyRoutesCache);
if (error) toasts.error(error || $t('proxies.loadFailed'));
}
$effect(() => {
+7 -5
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/stores';
import { getSettings } from '$lib/api';
import { settingsCache } from '$lib/stores/caches';
import { t } from '$lib/i18n';
import {
IconSettings,
@@ -18,12 +18,14 @@
interface Props { children: Snippet; }
let { children }: Props = $props();
let proxyProvider = $state('npm');
// Reactive to the shared settings cache: warm on revisit so the proxy-
// specific sub-nav items (NPM / Traefik) render correctly on first paint
// instead of defaulting to npm then snapping, and it updates live when the
// landing page saves a provider change.
const proxyProvider = $derived($settingsCache.value?.proxy_provider ?? 'npm');
$effect(() => {
getSettings().then((s) => {
proxyProvider = s.proxy_provider ?? 'npm';
}).catch(() => {});
settingsCache.refresh();
});
type NavGroup = 'main' | 'proxy' | 'system' | 'security';
+40 -16
View File
@@ -6,7 +6,11 @@
else — DNS, integrations, maintenance — lives on its own page now.
-->
<script lang="ts">
import { getSettings, updateSettings } from '$lib/api';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { updateSettings } from '$lib/api';
import type { Settings } from '$lib/types';
import { settingsCache } from '$lib/stores/caches';
import FormField from '$lib/components/FormField.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
@@ -77,23 +81,38 @@
return Object.keys(next).length === 0;
}
// Seed the form fields from a Settings object. Applied once on mount (from
// the warm cache if available, else from the network) — never re-applied
// during a background refresh, so it can't clobber unsaved edits.
function seedForm(s: Settings) {
domain = s.domain ?? '';
serverIp = s.server_ip ?? '';
publicIp = s.public_ip ?? '';
network = s.network ?? '';
subdomainPattern = s.subdomain_pattern ?? '';
pollingInterval = parseDurationToSeconds(s.polling_interval ?? '60');
baseVolumePath = s.base_volume_path ?? '';
proxyProvider = s.proxy_provider ?? 'npm';
}
async function loadSettings() {
loading = true;
try {
const s = await getSettings();
domain = s.domain ?? '';
serverIp = s.server_ip ?? '';
publicIp = s.public_ip ?? '';
network = s.network ?? '';
subdomainPattern = s.subdomain_pattern ?? '';
pollingInterval = parseDurationToSeconds(s.polling_interval ?? '60');
baseVolumePath = s.base_volume_path ?? '';
proxyProvider = s.proxy_provider ?? 'npm';
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
} finally {
// Warm cache → seed immediately and skip the skeleton (kills the
// entry flicker). Cold → keep the skeleton until the fetch resolves.
const cached = get(settingsCache).value;
if (cached) {
seedForm(cached);
loading = false;
}
await settingsCache.refresh();
const fresh = get(settingsCache).value;
// Only seed from the fetch when nothing was shown yet (cold load), so a
// background refresh never overwrites edits the user may have started.
if (fresh && loading) seedForm(fresh);
loading = false;
const { error } = get(settingsCache);
if (error && !cached) {
toasts.error(error || $t('settingsGeneral.loadFailed'));
}
}
async function handleSave() {
@@ -110,6 +129,11 @@
base_volume_path: baseVolumePath.trim(),
proxy_provider: proxyProvider
});
// Refetch the canonical Settings into the shared cache so the
// sub-layout's proxy-nav and the next visit reflect the save.
// (PUT /api/settings returns {status:"updated"}, not the object —
// so we must refresh, not set() the response.)
await settingsCache.refresh();
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
@@ -118,7 +142,7 @@
}
}
$effect(() => { loadSettings(); });
onMount(loadSettings);
</script>
<svelte:head>
+24 -24
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { t } from '$lib/i18n';
import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons';
import EmptyState from '$lib/components/EmptyState.svelte';
@@ -7,15 +8,14 @@
import Skeleton from '$lib/components/Skeleton.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import {
getAuthSettings,
updateAuthSettings,
listUsers as apiListUsers,
createUser,
deleteUser as apiDeleteUser,
ApiError,
type AuthSettings,
type AuthUser
} from '$lib/api';
import { authSettingsCache, usersCache } from '$lib/stores/caches';
type User = AuthUser;
@@ -23,7 +23,9 @@
let settings = $state<AuthSettings>({
auth_mode: 'local', oidc_client_id: '', oidc_client_secret: '', oidc_issuer_url: '', oidc_redirect_url: ''
});
let users = $state<User[]>([]);
// Users list is cache-backed (stale-while-revalidate); the auth-settings
// form is seeded once on mount from authSettingsCache.
const users = $derived<User[]>($usersCache.value);
let saving = $state(false);
let message = $state('');
let error = $state('');
@@ -33,34 +35,32 @@
let newEmail = $state('');
let newRole = $state('viewer');
// Refresh wrapper kept so the mutation handlers below read naturally.
const loadUsers = () => usersCache.refresh();
onMount(async () => {
try {
await Promise.all([loadSettings(), loadUsers()]);
} finally {
loading = false;
}
// Warm-seed the auth-settings form + skip the skeleton when both caches
// are already populated; otherwise keep the skeleton until the fetches
// resolve. The form is seeded once — a background refresh never
// re-applies it, so unsaved edits are safe.
const authState = get(authSettingsCache);
if (authState.loaded && authState.value) settings = { ...authState.value };
if (authState.loaded && get(usersCache).loaded) loading = false;
await Promise.all([authSettingsCache.refresh(), usersCache.refresh()]);
const fresh = get(authSettingsCache).value;
if (fresh && loading) settings = { ...fresh };
loading = false;
const e = get(authSettingsCache).error || get(usersCache).error;
if (e && !authState.loaded) error = e;
});
async function loadSettings() {
try {
settings = await getAuthSettings();
} catch (err: unknown) {
error = err instanceof ApiError ? err.message : $t('settingsAuth.loadFailed');
}
}
async function loadUsers() {
try {
users = (await apiListUsers()) ?? [];
} catch (err: unknown) {
error = err instanceof ApiError ? err.message : $t('settingsAuth.loadFailed');
}
}
async function saveSettings() {
saving = true; message = ''; error = '';
try {
await updateAuthSettings(settings);
// Keep the cache fresh for the next visit without re-seeding the
// current form (which already reflects the just-saved values).
void authSettingsCache.refresh();
message = $t('settingsAuth.saved');
} catch (err: unknown) {
error = err instanceof ApiError ? err.message : $t('settingsAuth.saveFailed');
+37 -27
View File
@@ -1,6 +1,9 @@
<script lang="ts">
import { getSettings, updateSettings, listBackups, triggerBackup, deleteBackup, restoreBackup, backupDownloadUrl } from '$lib/api';
import type { BackupInfo } from '$lib/types';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { updateSettings, triggerBackup, deleteBackup, restoreBackup, backupDownloadUrl } from '$lib/api';
import type { BackupInfo, Settings } from '$lib/types';
import { settingsCache, backupsCache } from '$lib/stores/caches';
import FormField from '$lib/components/FormField.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
@@ -14,39 +17,44 @@
let loading = $state(true);
let saving = $state(false);
let creatingBackup = $state(false);
let refreshing = $state(false);
let backupEnabled = $state(false);
let backupIntervalHours = $state('24');
let backupRetentionCount = $state('10');
let autoBackupBeforeDeploy = $state(false);
let backups = $state<BackupInfo[]>([]);
// Backups list is cache-backed (stale-while-revalidate); the settings form
// fields above are seeded once on mount from the shared settingsCache.
const backups = $derived($backupsCache.value);
const refreshing = $derived($backupsCache.refreshing);
let confirmDeleteId = $state('');
let confirmRestoreId = $state('');
// Seed the settings-form fields once from the shared cache; never re-applied
// during a background refresh, so unsaved edits are safe.
function seedForm(s: Settings) {
backupEnabled = s.backup_enabled ?? false;
backupIntervalHours = String(s.backup_interval_hours ?? 24);
backupRetentionCount = String(s.backup_retention_count ?? 10);
autoBackupBeforeDeploy = s.auto_backup_before_deploy ?? false;
}
async function loadData(refresh = false) {
// Explicit "refresh" button only refetches the backups list.
if (refresh) {
refreshing = true;
} else {
loading = true;
}
try {
const [settings, backupList] = await Promise.all([
getSettings(),
listBackups()
]);
backupEnabled = settings.backup_enabled ?? false;
backupIntervalHours = String(settings.backup_interval_hours ?? 24);
backupRetentionCount = String(settings.backup_retention_count ?? 10);
autoBackupBeforeDeploy = settings.auto_backup_before_deploy ?? false;
backups = backupList ?? [];
} catch (err) {
toasts.error(err instanceof Error ? err.message : 'Failed to load backup settings');
} finally {
loading = false;
refreshing = false;
await backupsCache.refresh();
return;
}
// Warm caches → seed form + skip the skeleton; cold → keep skeleton.
const sCached = get(settingsCache).value;
if (sCached) seedForm(sCached);
if (sCached && get(backupsCache).loaded) loading = false;
await Promise.all([settingsCache.refresh(), backupsCache.refresh()]);
const fresh = get(settingsCache).value;
if (fresh && loading) seedForm(fresh);
loading = false;
const err = get(settingsCache).error || get(backupsCache).error;
if (err && !sCached) toasts.error(err || 'Failed to load backup settings');
}
async function handleSave() {
@@ -58,6 +66,8 @@
backup_retention_count: Math.max(1, parseInt(backupRetentionCount, 10) || 10),
auto_backup_before_deploy: autoBackupBeforeDeploy
});
// PUT returns {status:"updated"}, not Settings — refetch the cache.
await settingsCache.refresh();
toasts.success($t('settingsBackup.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.saveFailed'));
@@ -69,8 +79,8 @@
async function handleBackupNow() {
creatingBackup = true;
try {
const backup = await triggerBackup();
backups = [backup, ...backups];
await triggerBackup();
await backupsCache.refresh();
toasts.success($t('settingsBackup.backupCreated'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.backupFailed'));
@@ -84,7 +94,7 @@
confirmDeleteId = '';
try {
await deleteBackup(id);
backups = backups.filter(b => b.id !== id);
await backupsCache.refresh();
toasts.success($t('settingsBackup.deleted'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.deleteFailed'));
@@ -133,7 +143,7 @@
return /Z|[+-]\d{2}:?\d{2}$/.test(dateStr) ? dateStr : dateStr + 'Z';
}
$effect(() => { loadData(); });
onMount(() => loadData());
</script>
<svelte:head>
+26 -15
View File
@@ -6,8 +6,11 @@
connection" flow aren't buried under unrelated infra fields.
-->
<script lang="ts">
import { getSettings, updateSettings, testDnsConnection, listDnsZones } from '$lib/api';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { updateSettings, testDnsConnection, listDnsZones } from '$lib/api';
import type { EntityPickerItem, Settings } from '$lib/types';
import { settingsCache } from '$lib/stores/caches';
import FormField from '$lib/components/FormField.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
@@ -30,22 +33,28 @@
let zoneName = $state('');
let testingDns = $state(false);
// Seed once from the shared settings cache (warm → no skeleton on revisit);
// never re-applied during background refresh, so unsaved edits are safe.
function seed(s: Settings) {
wildcardDns = s.wildcard_dns ?? true;
dnsProvider = s.dns_provider ?? '';
hasCloudflareApiToken = s.has_cloudflare_api_token ?? false;
cloudflareZoneId = s.cloudflare_zone_id ?? '';
if (!wildcardDns && cloudflareZoneId) resolveZoneName();
}
async function loadSettings() {
loading = true;
try {
const s = await getSettings();
wildcardDns = s.wildcard_dns ?? true;
dnsProvider = s.dns_provider ?? '';
hasCloudflareApiToken = s.has_cloudflare_api_token ?? false;
cloudflareZoneId = s.cloudflare_zone_id ?? '';
if (!wildcardDns && cloudflareZoneId) {
resolveZoneName();
}
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
} finally {
const cached = get(settingsCache).value;
if (cached) {
seed(cached);
loading = false;
}
await settingsCache.refresh();
const fresh = get(settingsCache).value;
if (fresh && loading) seed(fresh);
loading = false;
const { error } = get(settingsCache);
if (error && !cached) toasts.error(error || $t('settingsGeneral.loadFailed'));
}
async function handleSave() {
@@ -58,6 +67,8 @@
};
if (cloudflareApiToken) payload.cloudflare_api_token = cloudflareApiToken;
await updateSettings(payload);
// PUT returns {status:"updated"}, not Settings — refetch the cache.
await settingsCache.refresh();
toasts.success($t('settingsGeneral.saved'));
cloudflareApiToken = '';
hasCloudflareApiToken = hasCloudflareApiToken || Boolean(payload.cloudflare_api_token);
@@ -126,7 +137,7 @@
}
}
$effect(() => { loadSettings(); });
onMount(loadSettings);
</script>
<svelte:head>
@@ -8,13 +8,16 @@
detail pages — this page deliberately does not surface them.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import {
getSettings, updateSettings,
updateSettings,
getSettingsNotificationSecret,
regenerateSettingsNotificationSecret,
disableSettingsNotificationSigning,
testSettingsNotification,
} from '$lib/api';
import { settingsCache } from '$lib/stores/caches';
import FormField from '$lib/components/FormField.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
@@ -36,17 +39,24 @@
try { new URL(value.trim()); return ''; } catch { return $t('validation.invalidUrl'); }
}
// Seed once from the shared settings cache (warm → no skeleton on revisit);
// never re-applied during background refresh, so unsaved edits are safe.
async function load() {
loading = true;
try {
const settings = await getSettings();
notificationUrl = settings.notification_url ?? '';
const cached = get(settingsCache).value;
if (cached) {
notificationUrl = cached.notification_url ?? '';
savedNotificationUrl = notificationUrl;
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
} finally {
loading = false;
}
await settingsCache.refresh();
const fresh = get(settingsCache).value;
if (fresh && loading) {
notificationUrl = fresh.notification_url ?? '';
savedNotificationUrl = notificationUrl;
}
loading = false;
const { error } = get(settingsCache);
if (error && !cached) toasts.error(error || $t('settingsGeneral.loadFailed'));
}
async function handleSave() {
@@ -56,6 +66,8 @@
saving = true;
try {
await updateSettings({ notification_url: notificationUrl.trim() });
// PUT returns {status:"updated"}, not Settings — refetch the cache.
await settingsCache.refresh();
savedNotificationUrl = notificationUrl.trim();
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
@@ -65,7 +77,7 @@
}
}
$effect(() => { load(); });
onMount(load);
</script>
<svelte:head>
@@ -6,7 +6,11 @@
never within casual miss-click distance of general form fields.
-->
<script lang="ts">
import { getSettings, updateSettings, pruneImages, pruneBuildCache } from '$lib/api';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { updateSettings, pruneImages, pruneBuildCache } from '$lib/api';
import type { Settings } from '$lib/types';
import { settingsCache } from '$lib/stores/caches';
import FormField from '$lib/components/FormField.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
@@ -26,19 +30,28 @@
let statsIntervalSeconds = $state('15');
let statsRetentionHours = $state('2');
// Seed form fields once from the shared settings cache (warm → no skeleton
// on revisit); never re-applied during background refresh, so unsaved edits
// are safe. See caches.ts / settings landing page for the pattern.
function seed(s: Settings) {
staleThresholdDays = String(s.stale_threshold_days ?? 7);
imagePruneThresholdMb = String(s.image_prune_threshold_mb ?? 1024);
statsIntervalSeconds = String(s.stats_interval_seconds ?? 15);
statsRetentionHours = String(s.stats_retention_hours ?? 2);
}
async function load() {
loading = true;
try {
const s = await getSettings();
staleThresholdDays = String(s.stale_threshold_days ?? 7);
imagePruneThresholdMb = String(s.image_prune_threshold_mb ?? 1024);
statsIntervalSeconds = String(s.stats_interval_seconds ?? 15);
statsRetentionHours = String(s.stats_retention_hours ?? 2);
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
} finally {
const cached = get(settingsCache).value;
if (cached) {
seed(cached);
loading = false;
}
await settingsCache.refresh();
const fresh = get(settingsCache).value;
if (fresh && loading) seed(fresh);
loading = false;
const { error } = get(settingsCache);
if (error && !cached) toasts.error(error || $t('settingsGeneral.loadFailed'));
}
async function handleSave() {
@@ -59,6 +72,8 @@
stats_interval_seconds: interval,
stats_retention_hours: retention
});
// PUT returns {status:"updated"}, not Settings — refetch the cache.
await settingsCache.refresh();
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
@@ -91,7 +106,7 @@
}
}
$effect(() => { load(); });
onMount(load);
</script>
<svelte:head>
+35 -18
View File
@@ -1,6 +1,9 @@
<script lang="ts">
import { getSettings, updateSettings, listNpmCertificates, listNpmAccessLists, testNpmConnection } from '$lib/api';
import type { EntityPickerItem } from '$lib/types';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { updateSettings, listNpmCertificates, listNpmAccessLists, testNpmConnection } from '$lib/api';
import type { EntityPickerItem, Settings } from '$lib/types';
import { settingsCache } from '$lib/stores/caches';
import FormField from '$lib/components/FormField.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
@@ -41,20 +44,32 @@
return Object.keys(newErrors).length === 0;
}
// Seed once from the shared settings cache (warm → no skeleton on revisit);
// never re-applied during background refresh, so unsaved edits are safe.
function seed(s: Settings) {
npmUrl = s.npm_url ?? '';
npmEmail = s.npm_email ?? '';
npmHasCredentials = !!(s.npm_url && s.npm_email);
npmPassword = '';
sslCertificateId = s.ssl_certificate_id ?? 0;
npmRemote = s.npm_remote ?? false;
accessListId = s.npm_access_list_id ?? 0;
if (sslCertificateId > 0) sslCertName = `Certificate #${sslCertificateId}`;
if (accessListId > 0) accessListName = `Access List #${accessListId}`;
}
async function loadData() {
loading = true;
try {
const settings = await getSettings();
npmUrl = settings.npm_url ?? '';
npmEmail = settings.npm_email ?? '';
npmHasCredentials = !!(settings.npm_url && settings.npm_email);
npmPassword = '';
sslCertificateId = settings.ssl_certificate_id ?? 0;
npmRemote = settings.npm_remote ?? false;
accessListId = settings.npm_access_list_id ?? 0;
if (sslCertificateId > 0) sslCertName = `Certificate #${sslCertificateId}`;
if (accessListId > 0) accessListName = `Access List #${accessListId}`;
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.loadFailed')); } finally { loading = false; }
const cached = get(settingsCache).value;
if (cached) {
seed(cached);
loading = false;
}
await settingsCache.refresh();
const fresh = get(settingsCache).value;
if (fresh && loading) seed(fresh);
loading = false;
const { error } = get(settingsCache);
if (error && !cached) toasts.error(error || $t('settingsCredentials.loadFailed'));
}
async function handleTestConnection() {
@@ -91,6 +106,7 @@
const payload: Record<string, unknown> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim(), npm_remote: npmRemote };
if (npmPassword.trim()) payload.npm_password = npmPassword.trim();
await updateSettings(payload);
await settingsCache.refresh();
npmHasCredentials = true;
editingNpm = false;
npmPassword = '';
@@ -117,7 +133,7 @@
}
async function saveCertificate(id: number) {
try { await updateSettings({ ssl_certificate_id: id }); toasts.success($t('settingsCredentials.saved')); }
try { await updateSettings({ ssl_certificate_id: id }); await settingsCache.refresh(); toasts.success($t('settingsCredentials.saved')); }
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed')); }
}
@@ -148,7 +164,7 @@
}
async function saveAccessList(id: number) {
try { await updateSettings({ npm_access_list_id: id }); toasts.success($t('settingsCredentials.saved')); }
try { await updateSettings({ npm_access_list_id: id }); await settingsCache.refresh(); toasts.success($t('settingsCredentials.saved')); }
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed')); }
}
@@ -183,6 +199,7 @@
async function handleNpmRemoteChange() {
try {
await updateSettings({ npm_remote: npmRemote });
await settingsCache.refresh();
toasts.success($t('settingsCredentials.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed'));
@@ -193,7 +210,7 @@
await loadData();
await Promise.all([resolveCertName(), resolveAccessListName()]);
}
$effect(() => { init(); });
onMount(init);
</script>
<svelte:head>
+15 -11
View File
@@ -1,6 +1,9 @@
<script lang="ts">
import { listRegistries, createRegistry, updateRegistry, deleteRegistry, testRegistry } from '$lib/api';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { createRegistry, updateRegistry, deleteRegistry, testRegistry } from '$lib/api';
import type { Registry } from '$lib/types';
import { registriesCache } from '$lib/stores/caches';
import FormField from '$lib/components/FormField.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
@@ -9,8 +12,10 @@
import { IconPlus, IconLoader, IconEdit, IconTrash, IconWifi } from '$lib/components/icons';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let registries = $state<Registry[]>([]);
let loading = $state(true);
// Cache-backed (stale-while-revalidate) so revisiting this tab renders the
// registry list immediately instead of flashing the cold skeleton.
const registries = $derived($registriesCache.value);
const loading = $derived($registriesCache.loading);
let showForm = $state(false);
let editingId = $state<string | null>(null);
@@ -39,12 +44,11 @@
function startEdit(registry: Registry) { editingId = registry.id; formName = registry.name; formUrl = registry.url; formType = registry.type; formToken = ''; formOwner = registry.owner ?? ''; showForm = true; errors = {}; }
async function loadRegistryList() {
loading = true;
try {
registries = await listRegistries();
// Check health of all registries in the background.
checkAllHealth();
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsRegistries.loadFailed')); } finally { loading = false; }
await registriesCache.refresh();
const { error } = get(registriesCache);
if (error) { toasts.error(error || $t('settingsRegistries.loadFailed')); return; }
// Check health of all registries in the background.
checkAllHealth();
}
async function handleSave() {
@@ -78,7 +82,7 @@
}
async function checkAllHealth() {
const checks = registries.map(async (reg) => {
const checks = get(registriesCache).value.map(async (reg) => {
healthStatus[reg.id] = 'checking';
try {
await testRegistry(reg.id);
@@ -90,7 +94,7 @@
await Promise.allSettled(checks);
}
$effect(() => { loadRegistryList(); });
onMount(loadRegistryList);
</script>
<svelte:head>
+29 -12
View File
@@ -1,5 +1,9 @@
<script lang="ts">
import { getSettings, updateSettings } from '$lib/api';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { updateSettings } from '$lib/api';
import type { Settings } from '$lib/types';
import { settingsCache } from '$lib/stores/caches';
import FormField from '$lib/components/FormField.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import { toasts } from '$lib/stores/toast';
@@ -14,17 +18,28 @@
let traefikNetwork = $state('');
let traefikApiUrl = $state('');
// Seed form fields once from the shared settings cache (warm → no skeleton
// on revisit); never re-applied during background refresh, so unsaved edits
// are safe. See caches.ts / settings landing page for the pattern.
function seed(s: Settings) {
traefikEntrypoint = s.traefik_entrypoint ?? 'websecure';
traefikCertResolver = s.traefik_cert_resolver ?? 'letsencrypt';
traefikNetwork = s.traefik_network ?? '';
traefikApiUrl = s.traefik_api_url ?? '';
}
async function loadData() {
loading = true;
try {
const settings = await getSettings();
traefikEntrypoint = settings.traefik_entrypoint ?? 'websecure';
traefikCertResolver = settings.traefik_cert_resolver ?? 'letsencrypt';
traefikNetwork = settings.traefik_network ?? '';
traefikApiUrl = settings.traefik_api_url ?? '';
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
} finally { loading = false; }
const cached = get(settingsCache).value;
if (cached) {
seed(cached);
loading = false;
}
await settingsCache.refresh();
const fresh = get(settingsCache).value;
if (fresh && loading) seed(fresh);
loading = false;
const { error } = get(settingsCache);
if (error && !cached) toasts.error(error || $t('settingsGeneral.loadFailed'));
}
async function handleSave() {
@@ -36,13 +51,15 @@
traefik_network: traefikNetwork.trim(),
traefik_api_url: traefikApiUrl.trim()
});
// PUT returns {status:"updated"}, not Settings — refetch the cache.
await settingsCache.refresh();
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
} finally { saving = false; }
}
$effect(() => { loadData(); });
onMount(loadData);
</script>
<svelte:head>
+9 -25
View File
@@ -1,16 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as api from '$lib/api';
import type { SharedSecret } from '$lib/api';
import type { App } from '$lib/types';
import { sharedSecretsCache } from '$lib/stores/caches';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { t } from '$lib/i18n';
let secrets = $state<SharedSecret[]>([]);
let apps = $state<App[]>([]);
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. Apps are loaded
// alongside secrets (non-fatal) to resolve app-scoped row names. See caches.ts.
const secrets = $derived<SharedSecret[]>($sharedSecretsCache.value.secrets);
const apps = $derived($sharedSecretsCache.value.apps);
const loading = $derived($sharedSecretsCache.loading);
const error = $derived($sharedSecretsCache.error);
let filter = $state<'all' | 'global' | 'app'>('all');
const globals = $derived(secrets.filter((s) => s.scope === 'global'));
@@ -37,25 +39,7 @@
}
});
async function load(): Promise<void> {
loading = true;
error = '';
try {
// Load apps alongside secrets so app-scoped rows resolve their
// name. App load failure is non-fatal — the row falls back to
// the truncated id.
const [s, a] = await Promise.all([
api.listSharedSecrets(),
api.listApps().catch(() => [] as App[])
]);
secrets = s;
apps = a;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load shared secrets';
} finally {
loading = false;
}
}
const load = () => sharedSecretsCache.refresh();
function scopeLabel(s: SharedSecret): string {
if (s.scope === 'app') {
+46 -23
View File
@@ -5,6 +5,7 @@
import * as api from '$lib/api';
import type { SharedSecret, SharedSecretInput } from '$lib/api';
import type { EntityPickerItem, App } from '$lib/types';
import { sharedSecretDetailCache } from '$lib/stores/caches';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
@@ -79,37 +80,58 @@
appID = '';
}
function seedForm(s: SharedSecret): void {
name = s.name;
// value intentionally NOT populated — write-only.
value = '';
encrypted = s.encrypted;
loadedEncrypted = s.encrypted;
scope = s.scope;
appID = s.app_id;
description = s.description;
enabled = s.enabled;
}
// Keyed warm-seed: render the cached secret metadata instantly on revisit
// and seed the editable form ONCE per id (a background revalidation never
// re-seeds, so unsaved edits survive). The cached object carries no
// plaintext value (the API returns only `has_value`). Plain (non-reactive)
// guard. Apps (for the picker) are best-effort and loaded each visit.
let seededKey: string | null = null;
async function load(): Promise<void> {
if (id === '') {
error = 'Invalid secret id';
loading = false;
return;
}
loading = true;
error = '';
try {
// Load apps alongside the secret so the scope chip + picker
// resolve names. App load failure is non-fatal.
const [s, a] = await Promise.all([
api.getSharedSecret(id),
api.listApps().catch(() => [] as App[])
]);
secret = s;
name = s.name;
// value intentionally NOT populated — write-only.
value = '';
encrypted = s.encrypted;
loadedEncrypted = s.encrypted;
scope = s.scope;
appID = s.app_id;
description = s.description;
enabled = s.enabled;
apps = a;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load secret';
} finally {
const k = id;
const cached = sharedSecretDetailCache.peek(k);
if (cached.value) {
secret = cached.value;
if (seededKey !== k) { seedForm(cached.value); seededKey = k; }
loading = false;
} else {
loading = true;
}
error = '';
const appsPromise = api.listApps().catch(() => [] as App[]);
await sharedSecretDetailCache.refresh(k);
apps = await appsPromise;
// 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 !== k) return;
const entry = sharedSecretDetailCache.peek(k);
if (entry.error && !cached.value) {
error = entry.error || 'Failed to load secret';
loading = false;
return;
}
if (entry.value) {
secret = entry.value;
if (seededKey !== k) { seedForm(entry.value); seededKey = k; }
}
loading = false;
}
async function save(e?: Event): Promise<void> {
@@ -130,6 +152,7 @@
// include it only when the operator typed a new one (rotate).
if (value !== '') body.value = value;
secret = await api.updateSharedSecret(id, body);
sharedSecretDetailCache.set(id, secret);
// Re-baseline after a successful save: the new encrypted flag
// is now the loaded state, and the value field clears so a
// stale rotation can't be re-submitted.
+7 -15
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as api from '$lib/api';
import type { RedeployTrigger } from '$lib/api';
import { triggersCache } from '$lib/stores/caches';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { t } from '$lib/i18n';
@@ -13,9 +13,11 @@
const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule', 'webhook', 'logscan'] as const;
type KnownKind = (typeof KNOWN_KINDS)[number];
let triggers = $state<RedeployTrigger[]>([]);
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 triggers = $derived<RedeployTrigger[]>($triggersCache.value);
const loading = $derived($triggersCache.loading);
const error = $derived($triggersCache.error);
let kindFilter = $state<'all' | KnownKind | string>('all');
const filtered = $derived(
@@ -69,17 +71,7 @@
}
}
async function load(): Promise<void> {
loading = true;
error = '';
try {
triggers = await api.listTriggers();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load triggers';
} finally {
loading = false;
}
}
const load = () => triggersCache.refresh();
onMount(load);
</script>