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.
This commit is contained in:
@@ -578,6 +578,57 @@ export function backupDownloadUrl(id: string): string {
|
||||
return `/api/backups/${id}/download`;
|
||||
}
|
||||
|
||||
// ── Volume Snapshots ───────────────────────────────────────────────
|
||||
// Per-workload archives of host-bind data volumes. Capture-only for now
|
||||
// (create/list/delete/download); restore is a separate later phase.
|
||||
|
||||
export interface SnapshotInfo {
|
||||
id: string;
|
||||
workload_id: string;
|
||||
label: string;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
manifest: string; // JSON-encoded [{ index, target, scope, source }]
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SnapshotableVolume {
|
||||
target: string;
|
||||
scope: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface SkippedVolume {
|
||||
target: string;
|
||||
scope: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface SnapshotableInfo {
|
||||
volumes: SnapshotableVolume[];
|
||||
skipped: SkippedVolume[];
|
||||
}
|
||||
|
||||
export function listWorkloadSnapshots(workloadId: string, signal?: AbortSignal): Promise<SnapshotInfo[]> {
|
||||
return get<SnapshotInfo[]>(`/api/workloads/${workloadId}/snapshots`, signal);
|
||||
}
|
||||
|
||||
export function getWorkloadSnapshotable(workloadId: string, signal?: AbortSignal): Promise<SnapshotableInfo> {
|
||||
return get<SnapshotableInfo>(`/api/workloads/${workloadId}/snapshotable`, signal);
|
||||
}
|
||||
|
||||
export function createWorkloadSnapshot(workloadId: string, label?: string): Promise<SnapshotInfo> {
|
||||
return post<SnapshotInfo>(`/api/workloads/${workloadId}/snapshots`, label ? { label } : {});
|
||||
}
|
||||
|
||||
export function deleteSnapshot(sid: string): Promise<void> {
|
||||
return del<void>(`/api/snapshots/${sid}`);
|
||||
}
|
||||
|
||||
export function snapshotDownloadUrl(sid: string): string {
|
||||
return `/api/snapshots/${sid}/download`;
|
||||
}
|
||||
|
||||
// ── Health ──────────────────────────────────────────────────────────
|
||||
|
||||
export function getHealth(): Promise<{ docker: DockerHealth; proxy?: ProxyHealth }> {
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
<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>
|
||||
@@ -1670,6 +1670,33 @@
|
||||
"confirmDeleteTitle": "Delete notification route?",
|
||||
"confirmDeleteMessage": "This route will stop firing immediately. The workload's legacy notification URL (if set) will resume catching events when no routes match."
|
||||
},
|
||||
"snapshots": {
|
||||
"title": "Data snapshots",
|
||||
"sub": "Capture this app's host-bind data volumes as a downloadable archive.",
|
||||
"loading": "Loading snapshots…",
|
||||
"empty": "No snapshots yet.",
|
||||
"coverage": "{count} volume(s) will be captured.",
|
||||
"noCoverage": "No host-bind volumes to snapshot. Snapshots cover image-based apps with absolute, stage, or project volume scopes.",
|
||||
"liveWarning": "Snapshots are taken live — stop the app first for a fully consistent copy of databases.",
|
||||
"skippedTitle": "{count} volume(s) can't be snapshotted yet:",
|
||||
"labelPlaceholder": "Optional label (e.g. before upgrade)",
|
||||
"create": "Snapshot now",
|
||||
"creating": "Capturing…",
|
||||
"created": "Snapshot created.",
|
||||
"createFailed": "Failed to create snapshot",
|
||||
"colLabel": "Label",
|
||||
"colCreated": "Created",
|
||||
"colVolumes": "Volumes",
|
||||
"colSize": "Size",
|
||||
"unlabeled": "(unlabeled)",
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"deleted": "Snapshot deleted.",
|
||||
"downloadFailed": "Failed to download snapshot",
|
||||
"deleteFailed": "Failed to delete snapshot",
|
||||
"confirmDeleteTitle": "Delete snapshot?",
|
||||
"confirmDeleteMessage": "This permanently deletes the snapshot archive. This cannot be undone."
|
||||
},
|
||||
"toolbar": {
|
||||
"stop": "Stop",
|
||||
"start": "Start",
|
||||
|
||||
@@ -1670,6 +1670,33 @@
|
||||
"confirmDeleteTitle": "Удалить маршрут уведомлений?",
|
||||
"confirmDeleteMessage": "Маршрут перестанет срабатывать. Устаревший URL уведомлений на workload (если задан) снова возьмёт события на себя."
|
||||
},
|
||||
"snapshots": {
|
||||
"title": "Снимки данных",
|
||||
"sub": "Сохранение host-bind томов данных приложения в виде загружаемого архива.",
|
||||
"loading": "Загрузка снимков…",
|
||||
"empty": "Снимков пока нет.",
|
||||
"coverage": "Будет сохранено томов: {count}.",
|
||||
"noCoverage": "Нет host-bind томов для снимка. Снимки доступны для приложений на основе образа с томами в области absolute, stage или project.",
|
||||
"liveWarning": "Снимок делается «на лету» — остановите приложение для полностью согласованной копии баз данных.",
|
||||
"skippedTitle": "Не удастся сохранить томов: {count}:",
|
||||
"labelPlaceholder": "Необязательная метка (напр. перед обновлением)",
|
||||
"create": "Создать снимок",
|
||||
"creating": "Сохранение…",
|
||||
"created": "Снимок создан.",
|
||||
"createFailed": "Не удалось создать снимок",
|
||||
"colLabel": "Метка",
|
||||
"colCreated": "Создан",
|
||||
"colVolumes": "Тома",
|
||||
"colSize": "Размер",
|
||||
"unlabeled": "(без метки)",
|
||||
"download": "Скачать",
|
||||
"delete": "Удалить",
|
||||
"deleted": "Снимок удалён.",
|
||||
"downloadFailed": "Не удалось скачать снимок",
|
||||
"deleteFailed": "Не удалось удалить снимок",
|
||||
"confirmDeleteTitle": "Удалить снимок?",
|
||||
"confirmDeleteMessage": "Архив снимка будет удалён безвозвратно. Это действие нельзя отменить."
|
||||
},
|
||||
"toolbar": {
|
||||
"stop": "Стоп",
|
||||
"start": "Старт",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
import ContainerStats from '$lib/components/ContainerStats.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import WorkloadNotificationsPanel from '$lib/components/WorkloadNotificationsPanel.svelte';
|
||||
import WorkloadSnapshotsPanel from '$lib/components/WorkloadSnapshotsPanel.svelte';
|
||||
import TriggerKindForm, {
|
||||
createTriggerKindFormState,
|
||||
isTriggerFormValid,
|
||||
@@ -2765,6 +2766,11 @@
|
||||
<WorkloadNotificationsPanel workloadId={id} />
|
||||
{/if}
|
||||
|
||||
<!-- ── Data-volume snapshots (capture) ────────────── -->
|
||||
{#if !editing}
|
||||
<WorkloadSnapshotsPanel workloadId={id} />
|
||||
{/if}
|
||||
|
||||
<!-- ── Log scan rules (effective set) ─────────────── -->
|
||||
{#if !editing}
|
||||
<section class="panel" aria-labelledby="log-rules-heading">
|
||||
|
||||
Reference in New Issue
Block a user