Files
tiny-forge/web/src/routes/settings/maintenance/+page.svelte
T
alexei.dolgolyov 192204a51c
Build / build (push) Failing after 4m51s
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).
2026-06-08 15:39:25 +03:00

242 lines
8.5 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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; }}
/>