Files
tiny-forge/web/src/lib/components/WorkloadSnapshotsPanel.svelte
T
alexei.dolgolyov 6b45ed62bb
Build / build (push) Successful in 10m59s
feat(snapshots): capture app data-volume snapshots
Add per-workload capture of host-bind data volumes as downloadable tar.gz archives: a new internal/volsnap engine (enumerate host-bind volumes via the computeMounts merge, archive with archive/tar+gzip skipping symlinks/special files, per-workload retention + startup orphan cleanup), a volume_snapshots table + store CRUD, admin-gated API (list/snapshotable/create/download/delete), and a Snapshots panel on /apps/[id] that shows coverage and which volumes are skipped (and why). Scope: image-source apps, host-bind scopes (absolute/stage/project); Docker named volumes, tmpfs, and instance scope are surfaced as not-yet-supported. Restore is a separate later phase. Download/FilePath are containment-checked; create returns a typed no-data error (400) vs generic 500. Covered by archiver unit tests + full API e2e.
2026-06-02 14:56:10 +03:00

276 lines
7.6 KiB
Svelte

<script lang="ts">
/**
* WorkloadSnapshotsPanel
*
* Per-workload capture of host-bind data volumes (tar.gz). Create / list /
* download / delete. Restore is intentionally NOT here yet — overwriting
* live data needs container quiesce + atomic swap and ships separately.
*
* "Snapshotable" coverage is shown up-front (and which volumes are skipped,
* with why) so users are never misled about what is actually captured.
*/
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { toasts } from '$lib/stores/toast';
import { getAuthToken } from '$lib/auth';
import { formatBytes } from '$lib/format/bytes';
import ConfirmDialog from './ConfirmDialog.svelte';
import { IconPlus, IconTrash } from './icons';
interface Props {
workloadId: string;
}
let { workloadId }: Props = $props();
let snapshots = $state<api.SnapshotInfo[]>([]);
let snapshotable = $state<api.SnapshotableInfo | null>(null);
let loading = $state(true);
let creating = $state(false);
let error = $state('');
let label = $state('');
let confirmDeleteId = $state<string | null>(null);
const canSnapshot = $derived((snapshotable?.volumes.length ?? 0) > 0);
async function load(): Promise<void> {
loading = true;
error = '';
try {
[snapshots, snapshotable] = await Promise.all([
api.listWorkloadSnapshots(workloadId),
api.getWorkloadSnapshotable(workloadId)
]);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
// Reload whenever workloadId changes — the parent reuses this instance
// across /apps/A → /apps/B navigation.
$effect(() => {
const _ = workloadId;
load();
});
async function create(): Promise<void> {
if (creating || !canSnapshot) return;
creating = true;
try {
await api.createWorkloadSnapshot(workloadId, label.trim() || undefined);
label = '';
toasts.success($t('apps.detail.snapshots.created'));
await load();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('apps.detail.snapshots.createFailed'));
} finally {
creating = false;
}
}
async function doDelete(id: string): Promise<void> {
try {
await api.deleteSnapshot(id);
snapshots = snapshots.filter((s) => s.id !== id);
toasts.success($t('apps.detail.snapshots.deleted'));
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('apps.detail.snapshots.deleteFailed'));
} finally {
confirmDeleteId = null;
}
}
async function download(snap: api.SnapshotInfo): Promise<void> {
try {
const token = getAuthToken();
const res = await fetch(api.snapshotDownloadUrl(snap.id), {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = snap.filename;
a.click();
// Defer revocation: a.click() starts the download asynchronously, so
// revoking synchronously can race the navigation in some engines.
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('apps.detail.snapshots.downloadFailed'));
}
}
function volCount(manifest: string): number {
try {
return (JSON.parse(manifest) as unknown[]).length;
} catch {
return 0;
}
}
</script>
<section class="panel snap-panel" aria-labelledby="snap-heading">
<span class="reg reg-tl" aria-hidden="true"></span>
<span class="reg reg-tr" aria-hidden="true"></span>
<span class="reg reg-bl" aria-hidden="true"></span>
<span class="reg reg-br" aria-hidden="true"></span>
<header class="panel-head">
<h2 class="panel-title" id="snap-heading">
{$t('apps.detail.snapshots.title')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('apps.detail.snapshots.sub')}</span>
</header>
{#if error}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if loading}
<p class="hint">{$t('apps.detail.snapshots.loading')}</p>
{:else}
<!-- Coverage summary -->
{#if canSnapshot}
<p class="hint">
{$t('apps.detail.snapshots.coverage', { count: String(snapshotable?.volumes.length ?? 0) })}
</p>
<p class="hint warn">{$t('apps.detail.snapshots.liveWarning')}</p>
{:else}
<p class="hint">{$t('apps.detail.snapshots.noCoverage')}</p>
{/if}
{#if snapshotable && snapshotable.skipped.length > 0}
<div class="skipped">
<span class="skipped-title"
>{$t('apps.detail.snapshots.skippedTitle', { count: String(snapshotable.skipped.length) })}</span
>
<ul>
{#each snapshotable.skipped as sk (sk.target)}
<li><span class="mono">{sk.target}</span>{sk.reason}</li>
{/each}
</ul>
</div>
{/if}
<!-- Create -->
<div class="snap-create">
<input
type="text"
class="input"
bind:value={label}
placeholder={$t('apps.detail.snapshots.labelPlaceholder')}
autocomplete="off"
disabled={!canSnapshot || creating}
/>
<button class="forge-btn" onclick={create} disabled={!canSnapshot || creating}>
<IconPlus size={13} />
<span>{creating ? $t('apps.detail.snapshots.creating') : $t('apps.detail.snapshots.create')}</span>
</button>
</div>
<!-- List -->
{#if snapshots.length === 0}
<p class="hint">{$t('apps.detail.snapshots.empty')}</p>
{:else}
<table class="forge-table snap-table">
<thead>
<tr>
<th>{$t('apps.detail.snapshots.colLabel')}</th>
<th>{$t('apps.detail.snapshots.colCreated')}</th>
<th>{$t('apps.detail.snapshots.colVolumes')}</th>
<th>{$t('apps.detail.snapshots.colSize')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each snapshots as snap (snap.id)}
<tr>
<td>{snap.label || $t('apps.detail.snapshots.unlabeled')}</td>
<td class="mono-time">{snap.created_at}</td>
<td>{volCount(snap.manifest)}</td>
<td class="mono-time">{formatBytes(snap.size_bytes)}</td>
<td class="snap-actions">
<button class="forge-btn-ghost" onclick={() => download(snap)}>
{$t('apps.detail.snapshots.download')}
</button>
<button
class="forge-btn-ghost danger"
onclick={() => (confirmDeleteId = snap.id)}
aria-label={$t('apps.detail.snapshots.delete')}
>
<IconTrash size={13} />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
</section>
{#if confirmDeleteId}
<ConfirmDialog
open={true}
title={$t('apps.detail.snapshots.confirmDeleteTitle')}
message={$t('apps.detail.snapshots.confirmDeleteMessage')}
confirmLabel={$t('apps.detail.snapshots.delete')}
confirmVariant="danger"
onconfirm={() => confirmDeleteId && doDelete(confirmDeleteId)}
oncancel={() => (confirmDeleteId = null)}
/>
{/if}
<style>
.snap-panel {
margin-top: 1rem;
}
.hint {
font-size: 0.72rem;
color: var(--text-tertiary);
margin: 0.3rem 0;
}
.hint.warn {
color: var(--accent-warm, #c08458);
}
.skipped {
margin: 0.5rem 0;
padding: 0.5rem 0.7rem;
border: 1px dashed var(--border-primary);
border-radius: 4px;
}
.skipped-title {
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
}
.skipped ul {
margin: 0.35rem 0 0;
padding-left: 1.1rem;
font-size: 0.74rem;
color: var(--text-secondary);
}
.snap-create {
display: flex;
gap: 0.5rem;
margin: 0.85rem 0;
}
.snap-create .input {
flex: 1;
}
.snap-table {
width: 100%;
margin-top: 0.5rem;
}
.snap-actions {
display: flex;
gap: 0.35rem;
justify-content: flex-end;
}
</style>