From 192204a51c286d1881a9f97e2148ebadd30776cd Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 8 Jun 2026 15:39:25 +0300 Subject: [PATCH] feat(web): stale-while-revalidate caches to eliminate tab-switch flicker 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). --- web/src/lib/stores/caches.ts | 246 ++++++++++++++++++ web/src/lib/stores/eventsSnapshot.ts | 33 +++ web/src/lib/stores/resourceCache.ts | 209 +++++++++++++++ web/src/routes/+layout.svelte | 2 + web/src/routes/+page.svelte | 61 ++--- web/src/routes/apps/+page.svelte | 25 +- web/src/routes/apps/[id]/+page.svelte | 57 +++- web/src/routes/containers/+page.svelte | 36 +-- web/src/routes/containers/stale/+page.svelte | 39 +-- web/src/routes/dns/+page.svelte | 28 +- web/src/routes/event-triggers/+page.svelte | 22 +- .../routes/event-triggers/[id]/+page.svelte | 66 +++-- web/src/routes/events/+page.svelte | 23 +- web/src/routes/log-scan-rules/+page.svelte | 40 +-- .../routes/log-scan-rules/[id]/+page.svelte | 82 ++++-- .../routes/metric-alert-rules/+page.svelte | 22 +- .../metric-alert-rules/[id]/+page.svelte | 84 ++++-- web/src/routes/proxies/+page.svelte | 22 +- web/src/routes/settings/+layout.svelte | 12 +- web/src/routes/settings/+page.svelte | 56 ++-- web/src/routes/settings/auth/+page.svelte | 48 ++-- web/src/routes/settings/backup/+page.svelte | 64 +++-- web/src/routes/settings/dns/+page.svelte | 41 +-- .../routes/settings/integrations/+page.svelte | 30 ++- .../routes/settings/maintenance/+page.svelte | 39 ++- web/src/routes/settings/npm/+page.svelte | 53 ++-- .../routes/settings/registries/+page.svelte | 26 +- web/src/routes/settings/traefik/+page.svelte | 41 ++- web/src/routes/shared-secrets/+page.svelte | 34 +-- .../routes/shared-secrets/[id]/+page.svelte | 69 +++-- web/src/routes/triggers/+page.svelte | 22 +- 31 files changed, 1139 insertions(+), 493 deletions(-) create mode 100644 web/src/lib/stores/caches.ts create mode 100644 web/src/lib/stores/eventsSnapshot.ts create mode 100644 web/src/lib/stores/resourceCache.ts diff --git a/web/src/lib/stores/caches.ts b/web/src/lib/stores/caches.ts new file mode 100644 index 0000000..eb34ee1 --- /dev/null +++ b/web/src/lib/stores/caches.ts @@ -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( + (signal) => api.listWorkloads(undefined, signal), + [] +); + +export const containersCache = createResourceCache( + (signal) => api.listContainers({}, signal), + [] +); + +export const proxyRoutesCache = createResourceCache( + (signal) => api.listProxyRoutes(signal), + [] +); + +export const triggersCache = createResourceCache( + (signal) => api.listTriggers(undefined, signal), + [] +); + +export const eventTriggersCache = createResourceCache( + (signal) => api.listEventTriggers(signal), + [] +); + +export const metricAlertRulesCache = createResourceCache( + () => api.listMetricAlertRules(), + [] +); + +// ── Composite caches (pages that load several resources together) ───────── +export interface LogScanData { + rules: LogScanRule[]; + stats: LogScanStats | null; +} +export const logScanCache = createResourceCache( + 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( + 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( + 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( + (signal) => api.getSettings(signal), + null +); + +// Settings subpages with their own (non-/settings) data sources. +export const registriesCache = createResourceCache(() => api.listRegistries(), []); +export const backupsCache = createResourceCache(() => api.listBackups(), []); +export const authSettingsCache = createResourceCache( + () => api.getAuthSettings(), + null +); +export const usersCache = createResourceCache( + async () => (await api.listUsers()) ?? [], + [] +); + +// ── Drill-in LIST pages (single-value SWR) ──────────────────────────────── +export const staleContainersCache = createResourceCache( + (signal) => api.fetchStaleContainers(signal), + [] +); + +export interface DnsData { + records: DnsRecordView[]; + wildcardDns: boolean; +} +export const dnsCache = createResourceCache( + 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( + async (key, signal) => ({ + workload: await api.getWorkload(key, signal), + containers: await api.listWorkloadContainers(key, signal) + }) +); +export const triggerDetailCache = createKeyedResourceCache( + (key, signal) => api.getTrigger(key, signal) +); +export const eventTriggerDetailCache = createKeyedResourceCache( + (key, signal) => api.getEventTrigger(Number(key), signal) +); +export const logScanRuleDetailCache = createKeyedResourceCache( + (key, signal) => api.getLogScanRule(Number(key), signal) +); +export const metricAlertRuleDetailCache = createKeyedResourceCache( + (key, signal) => api.getMetricAlertRule(Number(key), signal) +); +export const sharedSecretDetailCache = createKeyedResourceCache( + (key, signal) => api.getSharedSecret(key, signal) +); + +const keyedCaches: KeyedResourceCache[] = [ + workloadDetailCache, + triggerDetailCache, + eventTriggerDetailCache, + logScanRuleDetailCache, + metricAlertRuleDetailCache, + sharedSecretDetailCache +] as KeyedResourceCache[]; + +/** Every cache, for bulk reset on logout. */ +const allCaches: ResourceCache[] = [ + workloadsCache, + containersCache, + proxyRoutesCache, + triggersCache, + eventTriggersCache, + metricAlertRulesCache, + logScanCache, + sharedSecretsCache, + dashboardCache, + settingsCache, + registriesCache, + backupsCache, + authSettingsCache, + usersCache, + staleContainersCache, + dnsCache +] as ResourceCache[]; + +export function resetAllCaches(): void { + for (const c of allCaches) c.reset(); + for (const c of keyedCaches) c.reset(); + clearEventsSnapshot(); +} diff --git a/web/src/lib/stores/eventsSnapshot.ts b/web/src/lib/stores/eventsSnapshot.ts new file mode 100644 index 0000000..e514b65 --- /dev/null +++ b/web/src/lib/stores/eventsSnapshot.ts @@ -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; +} diff --git a/web/src/lib/stores/resourceCache.ts b/web/src/lib/stores/resourceCache.ts new file mode 100644 index 0000000..b65521b --- /dev/null +++ b/web/src/lib/stores/resourceCache.ts @@ -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 { + /** 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 extends Readable> { + /** + * (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; + /** 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( + fetcher: (signal?: AbortSignal) => Promise, + initial: T +): ResourceCache { + const seed: ResourceState = { + value: initial, + loading: true, + refreshing: false, + error: '', + loaded: false + }; + const store = writable>(seed); + + let inFlight: AbortController | null = null; + + async function refresh(): Promise { + // 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 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 { + /** 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(): KeyedResourceState { + return { value: null, loading: true, refreshing: false, error: '', loaded: false }; +} + +export interface KeyedResourceCache extends Readable>> { + /** (Re)fetch one key; aborts a prior in-flight fetch for the same key. */ + refresh(key: string): Promise; + /** 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; + /** 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( + fetcher: (key: string, signal?: AbortSignal) => Promise +): KeyedResourceCache { + const store = writable>>(new Map()); + const inFlight = new Map(); + + function patch(key: string, partial: Partial>): void { + store.update((m) => { + const next = new Map(m); + next.set(key, { ...(next.get(key) ?? coldKeyed()), ...partial }); + return next; + }); + } + + async function refresh(key: string): Promise { + 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 { + return get(store).get(key) ?? coldKeyed(); + } + + 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 }; +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 1fbda8a..8367284 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -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" diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 7ff495f..c79f58f 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -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([]); - let containers = $state([]); - let staleContainers = $state([]); - 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/ diff --git a/web/src/routes/apps/+page.svelte b/web/src/routes/apps/+page.svelte index 7e132b6..fd214dc 100644 --- a/web/src/routes/apps/+page.svelte +++ b/web/src/routes/apps/+page.svelte @@ -1,14 +1,17 @@ diff --git a/web/src/routes/dns/+page.svelte b/web/src/routes/dns/+page.svelte index e098aa6..7e829c1 100644 --- a/web/src/routes/dns/+page.svelte +++ b/web/src/routes/dns/+page.svelte @@ -1,15 +1,18 @@ diff --git a/web/src/routes/settings/auth/+page.svelte b/web/src/routes/settings/auth/+page.svelte index 559473e..e6dc9d4 100644 --- a/web/src/routes/settings/auth/+page.svelte +++ b/web/src/routes/settings/auth/+page.svelte @@ -1,5 +1,6 @@ diff --git a/web/src/routes/settings/dns/+page.svelte b/web/src/routes/settings/dns/+page.svelte index 2418fda..151795c 100644 --- a/web/src/routes/settings/dns/+page.svelte +++ b/web/src/routes/settings/dns/+page.svelte @@ -6,8 +6,11 @@ connection" flow aren't buried under unrelated infra fields. --> diff --git a/web/src/routes/settings/integrations/+page.svelte b/web/src/routes/settings/integrations/+page.svelte index 9cbb3be..966ef80 100644 --- a/web/src/routes/settings/integrations/+page.svelte +++ b/web/src/routes/settings/integrations/+page.svelte @@ -8,13 +8,16 @@ detail pages — this page deliberately does not surface them. --> diff --git a/web/src/routes/settings/maintenance/+page.svelte b/web/src/routes/settings/maintenance/+page.svelte index c219551..9836299 100644 --- a/web/src/routes/settings/maintenance/+page.svelte +++ b/web/src/routes/settings/maintenance/+page.svelte @@ -6,7 +6,11 @@ never within casual miss-click distance of general form fields. --> diff --git a/web/src/routes/settings/npm/+page.svelte b/web/src/routes/settings/npm/+page.svelte index 196ee97..75de482 100644 --- a/web/src/routes/settings/npm/+page.svelte +++ b/web/src/routes/settings/npm/+page.svelte @@ -1,6 +1,9 @@ diff --git a/web/src/routes/settings/registries/+page.svelte b/web/src/routes/settings/registries/+page.svelte index e3f3aed..c46a27a 100644 --- a/web/src/routes/settings/registries/+page.svelte +++ b/web/src/routes/settings/registries/+page.svelte @@ -1,6 +1,9 @@ diff --git a/web/src/routes/settings/traefik/+page.svelte b/web/src/routes/settings/traefik/+page.svelte index 9d62f79..4bc6b06 100644 --- a/web/src/routes/settings/traefik/+page.svelte +++ b/web/src/routes/settings/traefik/+page.svelte @@ -1,5 +1,9 @@ diff --git a/web/src/routes/shared-secrets/+page.svelte b/web/src/routes/shared-secrets/+page.svelte index dacfba8..98ce846 100644 --- a/web/src/routes/shared-secrets/+page.svelte +++ b/web/src/routes/shared-secrets/+page.svelte @@ -1,16 +1,18 @@