192204a51c
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).
242 lines
8.5 KiB
Svelte
242 lines
8.5 KiB
Svelte
<!--
|
||
Settings › Maintenance
|
||
|
||
Housekeeping that's adjacent to destructive operations: stale-container
|
||
thresholds and Docker-image pruning. Isolated so the prune button is
|
||
never within casual miss-click distance of general form fields.
|
||
-->
|
||
<script lang="ts">
|
||
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';
|
||
import { toasts } from '$lib/stores/toast';
|
||
import { t } from '$lib/i18n';
|
||
import { IconLoader, IconAlert } from '$lib/components/icons';
|
||
|
||
let loading = $state(true);
|
||
let saving = $state(false);
|
||
let pruning = $state(false);
|
||
let showPruneConfirm = $state(false);
|
||
let pruningCache = $state(false);
|
||
let showPruneCacheConfirm = $state(false);
|
||
|
||
let staleThresholdDays = $state('7');
|
||
let imagePruneThresholdMb = $state('1024');
|
||
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() {
|
||
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() {
|
||
saving = true;
|
||
try {
|
||
const intervalParsed = parseInt(statsIntervalSeconds, 10);
|
||
const retentionParsed = parseInt(statsRetentionHours, 10);
|
||
// Interval 0 disables collection; otherwise clamp to [5, 300].
|
||
const interval = isNaN(intervalParsed)
|
||
? 15
|
||
: intervalParsed === 0
|
||
? 0
|
||
: Math.max(5, Math.min(300, intervalParsed));
|
||
const retention = isNaN(retentionParsed) ? 2 : Math.max(0, Math.min(24, retentionParsed));
|
||
await updateSettings({
|
||
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
|
||
image_prune_threshold_mb: Math.max(0, parseInt(imagePruneThresholdMb, 10) || 0),
|
||
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'));
|
||
} finally {
|
||
saving = false;
|
||
}
|
||
}
|
||
|
||
async function handlePruneImages() {
|
||
pruning = true;
|
||
try {
|
||
const result = await pruneImages();
|
||
toasts.success($t('settings.pruneResult', { count: String(result.images_removed), mb: String(result.space_reclaimed_mb) }));
|
||
} catch (err) {
|
||
toasts.error(err instanceof Error ? err.message : $t('settings.pruneFailed'));
|
||
} finally {
|
||
pruning = false;
|
||
}
|
||
}
|
||
|
||
async function handlePruneBuildCache() {
|
||
pruningCache = true;
|
||
try {
|
||
const result = await pruneBuildCache();
|
||
toasts.success($t('settings.pruneCacheResult', { count: String(result.caches_deleted), mb: String(result.space_reclaimed_mb) }));
|
||
} catch (err) {
|
||
toasts.error(err instanceof Error ? err.message : $t('settings.pruneCacheFailed'));
|
||
} finally {
|
||
pruningCache = false;
|
||
}
|
||
}
|
||
|
||
onMount(load);
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>{$t('settingsMaintenance.title')} - {$t('app.name')}</title>
|
||
</svelte:head>
|
||
|
||
<div class="space-y-6">
|
||
{#if loading}
|
||
<div class="space-y-4">
|
||
<Skeleton height="2rem" width="12rem" />
|
||
<Skeleton height="6rem" />
|
||
<Skeleton height="8rem" />
|
||
</div>
|
||
{:else}
|
||
<!-- Thresholds card -->
|
||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsMaintenance.thresholds')}</h2>
|
||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('settingsMaintenance.thresholdsDesc')}</p>
|
||
|
||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<FormField
|
||
label={$t('settings.staleThreshold')}
|
||
name="staleThresholdDays"
|
||
type="number"
|
||
bind:value={staleThresholdDays}
|
||
placeholder="7"
|
||
helpText={$t('settings.staleThresholdHelp')}
|
||
/>
|
||
<FormField
|
||
label={$t('settings.pruneThreshold')}
|
||
name="imagePruneThresholdMb"
|
||
type="number"
|
||
bind:value={imagePruneThresholdMb}
|
||
placeholder="1024"
|
||
helpText={$t('settings.pruneThresholdHelp')}
|
||
/>
|
||
<FormField
|
||
label={$t('statsSettings.intervalLabel')}
|
||
name="statsIntervalSeconds"
|
||
type="number"
|
||
bind:value={statsIntervalSeconds}
|
||
placeholder="15"
|
||
helpText={$t('statsSettings.intervalHelp')}
|
||
/>
|
||
<FormField
|
||
label={$t('statsSettings.retentionLabel')}
|
||
name="statsRetentionHours"
|
||
type="number"
|
||
bind:value={statsRetentionHours}
|
||
placeholder="2"
|
||
helpText={$t('statsSettings.retentionHelp')}
|
||
/>
|
||
</div>
|
||
|
||
<div class="mt-6">
|
||
<button onclick={handleSave} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
|
||
{#if saving}<IconLoader size={16} />{/if}
|
||
{saving ? $t('settingsGeneral.saving') : $t('settingsGeneral.saveSettings')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Danger zone: prune images -->
|
||
<div class="rounded-xl border border-[var(--color-danger)]/40 bg-[var(--color-danger-light)]/30 p-6">
|
||
<div class="flex items-start gap-3">
|
||
<div class="shrink-0 rounded-lg bg-[var(--color-danger-light)] p-2 text-[var(--color-danger)]">
|
||
<IconAlert size={18} />
|
||
</div>
|
||
<div class="min-w-0 flex-1">
|
||
<h2 class="text-lg font-semibold text-[var(--color-danger-dark)]">{$t('settingsMaintenance.dangerZone')}</h2>
|
||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('settings.dockerCleanupHelp')}</p>
|
||
|
||
<div class="mt-4">
|
||
<button
|
||
type="button"
|
||
onclick={() => { showPruneConfirm = true; }}
|
||
disabled={pruning}
|
||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger)] hover:text-white disabled:opacity-50 transition-colors"
|
||
>
|
||
{#if pruning}
|
||
<IconLoader size={16} />
|
||
{$t('settings.pruning')}
|
||
{:else}
|
||
{$t('settings.pruneImages')}
|
||
{/if}
|
||
</button>
|
||
</div>
|
||
|
||
<hr class="my-5 border-[var(--color-danger)]/20" />
|
||
|
||
<p class="text-sm text-[var(--text-secondary)]">{$t('settings.buildCacheCleanupHelp')}</p>
|
||
|
||
<div class="mt-4">
|
||
<button
|
||
type="button"
|
||
onclick={() => { showPruneCacheConfirm = true; }}
|
||
disabled={pruningCache}
|
||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger)] hover:text-white disabled:opacity-50 transition-colors"
|
||
>
|
||
{#if pruningCache}
|
||
<IconLoader size={16} />
|
||
{$t('settings.pruningCache')}
|
||
{:else}
|
||
{$t('settings.pruneBuildCache')}
|
||
{/if}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<ConfirmDialog
|
||
open={showPruneConfirm}
|
||
title={$t('settings.pruneImages')}
|
||
message={$t('settings.pruneConfirmMessage')}
|
||
confirmLabel={$t('settings.pruneImages')}
|
||
confirmVariant="danger"
|
||
onconfirm={() => { showPruneConfirm = false; handlePruneImages(); }}
|
||
oncancel={() => { showPruneConfirm = false; }}
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
open={showPruneCacheConfirm}
|
||
title={$t('settings.pruneBuildCache')}
|
||
message={$t('settings.pruneBuildCacheConfirmMessage')}
|
||
confirmLabel={$t('settings.pruneBuildCache')}
|
||
confirmVariant="danger"
|
||
onconfirm={() => { showPruneCacheConfirm = false; handlePruneBuildCache(); }}
|
||
oncancel={() => { showPruneCacheConfirm = false; }}
|
||
/>
|