backup engine internals - streaming, timeouts, typed snapshot layout, volume keys, route guards
This commit is contained in:
32 files changed
+1205
-352
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { auditBackup } from '$lib/server/audit';
|
||||
import {
|
||||
getBackupConfigs,
|
||||
@@ -12,9 +13,8 @@ import { validateRetention, retentionToStore } from '$lib/server/backups/helpers
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
|
||||
const type = url.searchParams.get('type') ?? undefined;
|
||||
const targetName = url.searchParams.get('target') ?? undefined;
|
||||
@@ -45,9 +45,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const denied = await requireBackups(auth, 'manage');
|
||||
if (denied) return denied;
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { auditBackupDestination } from '$lib/server/audit';
|
||||
import { getBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db';
|
||||
import { initRepository } from '$lib/server/backups';
|
||||
@@ -8,9 +9,8 @@ import { initRepository } from '$lib/server/backups';
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const denied = await requireBackups(auth, 'manage');
|
||||
if (denied) return denied;
|
||||
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { auditBackupDestination } from '$lib/server/audit';
|
||||
import { getBackupDestination } from '$lib/server/db';
|
||||
import { rotateDestinationPassword } from '$lib/server/backups';
|
||||
@@ -20,9 +21,8 @@ import { rotateDestinationPassword } from '$lib/server/backups';
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const denied = await requireBackups(auth, 'manage');
|
||||
if (denied) return denied;
|
||||
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import {
|
||||
getBackupDestination,
|
||||
updateBackupDestinationTestStatus
|
||||
@@ -9,9 +10,8 @@ import { testRepository } from '$lib/server/backups';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const denied = await requireBackups(auth, 'manage');
|
||||
if (denied) return denied;
|
||||
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { auditBackupDestination } from '$lib/server/audit';
|
||||
import { getBackupDestination } from '$lib/server/db';
|
||||
import { verifyBackup } from '$lib/server/backups';
|
||||
@@ -9,9 +10,8 @@ import { createJobResponse } from '$lib/server/sse';
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const denied = await requireBackups(auth, 'manage');
|
||||
if (denied) return denied;
|
||||
|
||||
const destinationId = parseInt(params.id);
|
||||
if (isNaN(destinationId)) return json({ error: 'Invalid destination ID' }, { status: 400 });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { auditRestore } from '$lib/server/audit';
|
||||
import { cancelRestore } from '$lib/server/backups';
|
||||
import { isValidSnapshotId } from '$lib/server/backups/helpers';
|
||||
@@ -14,9 +15,8 @@ import { isValidSnapshotId } from '$lib/server/backups/helpers';
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const denied = await requireBackups(auth, 'manage');
|
||||
if (denied) return denied;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const snapshotId: string | undefined = body.snapshotId;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { auditBackup } from '$lib/server/audit';
|
||||
import { getBackupDestination } from '$lib/server/db';
|
||||
import { forgetSnapshot } from '$lib/server/backups';
|
||||
@@ -10,9 +11,8 @@ import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const rbacDenied = await requireBackups(auth, 'manage');
|
||||
if (rbacDenied) return rbacDenied;
|
||||
|
||||
const snapshotId = params.id;
|
||||
const invalidSnap = validateSnapshotId(snapshotId);
|
||||
|
||||
@@ -2,14 +2,14 @@ import { json } from '@sveltejs/kit';
|
||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { browseSnapshot } from '$lib/server/backups';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
|
||||
const snapshotId = params.id;
|
||||
const invalidSnap = validateSnapshotId(snapshotId);
|
||||
|
||||
@@ -2,14 +2,15 @@ import { json } from '@sveltejs/kit';
|
||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { dumpSnapshotFile, dumpSnapshotFileBytes, dumpSnapshotArchive } from '$lib/server/backups';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { parseSnapshotLayout, redactSnapshotLayout } from '$lib/server/backups/snapshot-layout';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
|
||||
const snapshotId = params.id;
|
||||
const invalidSnap = validateSnapshotId(snapshotId);
|
||||
@@ -48,6 +49,26 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
return json({ error: 'Invalid path' }, { status: 400 });
|
||||
}
|
||||
|
||||
// metadata.json carries secrets (stack.secrets ciphertext + container Config.Env
|
||||
// plaintext). It must ONLY leave the process through the redacting path — never as a
|
||||
// raw file/byte/archive dump, which would bypass redaction. Serve a redacted inline
|
||||
// preview; refuse raw downloads and reject a /metadata archive dump that would embed it.
|
||||
const isMetadataFile = path === '/metadata/metadata.json';
|
||||
if (isMetadataFile || (isDir && download && (path === '/metadata' || path === '/metadata/'))) {
|
||||
if (download) {
|
||||
return json({ error: 'metadata.json cannot be downloaded raw; use the snapshot metadata endpoint (secrets are redacted there)' }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const raw = await dumpSnapshotFile(destinationId, snapshotId, '/metadata/metadata.json');
|
||||
const layout = parseSnapshotLayout(raw);
|
||||
if (!layout) return json({ error: 'metadata unreadable' }, { status: 404 });
|
||||
return json({ content: JSON.stringify(redactSnapshotLayout(layout), null, 2) });
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize filename for Content-Disposition (strip quotes, backslashes, control chars)
|
||||
const sanitizeFilename = (name: string) => name.replace(/["\\\x00-\x1f]/g, '_');
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@ import { json } from '@sveltejs/kit';
|
||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { getSnapshotMetadata } from '$lib/server/backups';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { redactSnapshotLayout } from '$lib/server/backups/snapshot-layout';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
|
||||
const snapshotId = params.id;
|
||||
const invalidSnap = validateSnapshotId(snapshotId);
|
||||
@@ -28,15 +29,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
try {
|
||||
const metadata = await getSnapshotMetadata(destinationId, snapshotId);
|
||||
if (!metadata) return json({ error: 'No metadata available' }, { status: 404 });
|
||||
// Never expose the stored secret VALUES (ciphertext) to the client — replace the
|
||||
// `secrets` array with `secretKeys` (names only) so the UI can list them.
|
||||
const { secrets, ...safe } = metadata as Record<string, unknown>;
|
||||
if (Array.isArray(secrets)) {
|
||||
(safe as Record<string, unknown>).secretKeys = secrets
|
||||
.map((s: any) => s?.key)
|
||||
.filter((k: unknown): k is string => typeof k === 'string');
|
||||
}
|
||||
return json(safe);
|
||||
// Single redaction point: strips stack.secrets values AND container inspect
|
||||
// Config.Env/Labels (plaintext secrets) before anything reaches the client.
|
||||
return json(redactSnapshotLayout(metadata));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: msg }, { status: 500 });
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { requireBackups } from '$lib/server/backups/route-guards';
|
||||
import { diffSnapshots } from '$lib/server/backups';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('backups', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
|
||||
const destId = url.searchParams.get('destinationId');
|
||||
const snapA = url.searchParams.get('snapshotA');
|
||||
|
||||
@@ -106,6 +106,21 @@
|
||||
const isVolumeMode = $derived(mode === 'volume');
|
||||
const isSnapshotMode = $derived(mode === 'snapshot');
|
||||
|
||||
// In a snapshot, the stack dir rides a reserved volume key; show it by what it holds,
|
||||
// not the raw internal key. Navigation still uses the real name.
|
||||
function displayEntryName(name: string): string {
|
||||
if (isSnapshotMode && name === '__dockhand_stackdir__') return 'Stack files (compose, config)';
|
||||
return name;
|
||||
}
|
||||
|
||||
// A raw download of the snapshot's metadata.json is refused server-side (403) — it carries
|
||||
// secrets and must only leave through the redacting metadata endpoint. So don't offer a
|
||||
// download button that would always fail; the file is still previewable (redacted).
|
||||
function canDownloadEntry(entry: FileEntry): boolean {
|
||||
if (!isSnapshotMode) return true;
|
||||
return !(entry.name === 'metadata.json' && (currentPath === '/metadata' || currentPath === '/metadata/'));
|
||||
}
|
||||
|
||||
// Effective canEdit: snapshots are always read-only; volumes only if not in use
|
||||
const effectiveCanEdit = $derived(
|
||||
isSnapshotMode ? false : isVolumeMode ? (canEdit && !volumeIsInUse) : canEdit
|
||||
@@ -934,7 +949,7 @@
|
||||
title={segment}
|
||||
onclick={() => navigateTo('/' + pathSegments().slice(0, i + 1).join('/'))}
|
||||
>
|
||||
{segment}
|
||||
{displayEntryName(segment)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1076,14 +1091,16 @@
|
||||
onclick={() => handleEntryClick(entry)}
|
||||
>
|
||||
<Icon
|
||||
class="w-3.5 h-3.5 shrink-0 {entry.type === 'directory'
|
||||
? 'text-blue-500'
|
||||
: entry.type === 'symlink'
|
||||
? 'text-purple-500'
|
||||
: 'text-muted-foreground'}"
|
||||
class="w-3.5 h-3.5 shrink-0 {isSnapshotMode && entry.name === '__dockhand_stackdir__'
|
||||
? 'text-amber-500'
|
||||
: entry.type === 'directory'
|
||||
? 'text-blue-500'
|
||||
: entry.type === 'symlink'
|
||||
? 'text-purple-500'
|
||||
: 'text-muted-foreground'}"
|
||||
/>
|
||||
<span class="truncate" title={entry.name}>
|
||||
{entry.name}
|
||||
{displayEntryName(entry.name)}
|
||||
{#if entry.type === 'symlink' && entry.linkTarget}
|
||||
<span class="text-muted-foreground ml-1">
|
||||
→ {entry.linkTarget}
|
||||
@@ -1188,15 +1205,17 @@
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); downloadFile(entry); }}
|
||||
title="Download"
|
||||
>
|
||||
<Download class="w-3 h-3" />
|
||||
</Button>
|
||||
{#if canDownloadEntry(entry)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); downloadFile(entry); }}
|
||||
title="Download"
|
||||
>
|
||||
<Download class="w-3 h-3" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
||||
import { environments } from '$lib/stores/environment';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import SnapshotHeader from '$lib/components/backup/SnapshotHeader.svelte';
|
||||
import FileBrowserPanel from './FileBrowserPanel.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -105,33 +106,21 @@
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-6xl h-[90vh] sm:h-[85vh] flex flex-col overflow-hidden">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2 flex-wrap">
|
||||
<FolderOpen class="w-5 h-5" />
|
||||
Browse snapshot
|
||||
{#if snapshotEnvName}
|
||||
<span class="text-muted-foreground font-normal">from</span>
|
||||
<EnvironmentIcon icon={snapshotEnvIcon} envId={snapshotEnvId} class="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||
<span class="font-normal text-amber-600 dark:text-amber-400">{snapshotEnvName}</span>
|
||||
{/if}
|
||||
{#if displayName}
|
||||
<span class="text-muted-foreground font-normal">—</span>
|
||||
{#if (targetType || metadata?.type) === 'stack'}
|
||||
<Layers class="w-4 h-4 text-purple-500" />
|
||||
{:else}
|
||||
<Box class="w-4 h-4 text-blue-500" />
|
||||
{/if}
|
||||
<span class="font-normal">{displayName}</span>
|
||||
{/if}
|
||||
{#if destinationName}
|
||||
<span class="text-muted-foreground font-normal">on</span>
|
||||
<svelte:component this={getRepoTypeIcon(destinationRepo)} class="w-4 h-4" />
|
||||
<span class="font-normal">{destinationName}</span>
|
||||
{/if}
|
||||
<Dialog.Title>
|
||||
<SnapshotHeader
|
||||
icon={FolderOpen}
|
||||
verb="Browse snapshot"
|
||||
name={displayName}
|
||||
nameType={(targetType || metadata?.type) === 'stack' ? 'stack' : 'container'}
|
||||
{destinationName}
|
||||
destinationRepository={destinationRepo}
|
||||
sourceEnv={{ id: snapshotEnvId, icon: snapshotEnvIcon }}
|
||||
sourceEnvName={snapshotEnvName}
|
||||
snapshotId={snapshotId ? String(snapshotId) : undefined}
|
||||
{snapshotTime}
|
||||
/>
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{#if snapshotId}<Badge variant="outline" class="font-mono text-[10px]">{String(snapshotId).slice(0, 8)}</Badge>{/if}
|
||||
{#if snapshotTime}{formatDateTime(snapshotTime)} <span class="opacity-60">({formatRelativeTime(snapshotTime)})</span>{/if}
|
||||
</Dialog.Description>
|
||||
<Dialog.Description class="sr-only">Browse files in snapshot {snapshotId ? String(snapshotId).slice(0, 8) : ''}.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<!-- Tabs -->
|
||||
@@ -192,12 +181,12 @@
|
||||
<dt class="text-muted-foreground">Volumes</dt>
|
||||
<dd>{metadata.volumes?.length ?? 0}</dd>
|
||||
{#if metadata.type === 'stack'}
|
||||
{#if metadata.composeFileName}
|
||||
{#if metadata.stack?.composeFileName}
|
||||
<dt class="text-muted-foreground">Compose file</dt>
|
||||
<dd class="font-mono">{metadata.composeFileName}</dd>
|
||||
<dd class="font-mono">{metadata.stack.composeFileName}</dd>
|
||||
{/if}
|
||||
<dt class="text-muted-foreground">Stack files</dt>
|
||||
<dd>{metadata.hasStackFiles ? (metadata.stackFilesTruncated ? 'captured (truncated)' : 'captured') : 'not captured'}</dd>
|
||||
<dd>{metadata.hasStackFiles ? 'captured' : 'not captured'}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</section>
|
||||
@@ -229,34 +218,31 @@
|
||||
<!-- Stack files (captured under stackfiles/). The list is recorded in
|
||||
metadata.json at backup time so we can enumerate the exact files
|
||||
without a browse roundtrip. -->
|
||||
{#if metadata.type === 'stack' && metadata.stackFilesList?.length}
|
||||
{#if metadata.type === 'stack' && metadata.stack?.fileList?.length}
|
||||
<section class="space-y-2 border-t pt-4">
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><FileText class="w-3.5 h-3.5" />Stack files ({metadata.stackFilesList.length}{metadata.stackFilesListTruncated ? '+' : ''})</h4>
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><FileText class="w-3.5 h-3.5" />Stack files ({metadata.stack.fileList.length})</h4>
|
||||
<div class="space-y-1">
|
||||
{#each metadata.stackFilesList as file}
|
||||
{#each metadata.stack.fileList as file}
|
||||
<div class="flex items-center gap-2 text-sm bg-muted/30 rounded px-2 py-1.5">
|
||||
<span class="font-mono truncate flex-1" class:font-medium={file.path === metadata.composeFileName}>{file.path}</span>
|
||||
{#if file.path === metadata.composeFileName}
|
||||
<span class="font-mono truncate flex-1" class:font-medium={file.path === metadata.stack.composeFileName}>{file.path}</span>
|
||||
{#if file.path === metadata.stack.composeFileName}
|
||||
<Badge variant="outline" class="text-[10px] shrink-0">compose</Badge>
|
||||
{/if}
|
||||
<span class="text-muted-foreground text-xs shrink-0 tabular-nums">{formatBytes(file.bytes)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if metadata.stackFilesListTruncated}
|
||||
<p class="text-xs text-muted-foreground">Only the first {metadata.stackFilesList.length} files are listed. Browse the snapshot to see the full tree.</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Secrets carried in the snapshot (KEY NAMES only — the values are
|
||||
stored encrypted and never exposed here). Restored to the target
|
||||
DB on restore unless the user opts out. -->
|
||||
{#if metadata.type === 'stack' && metadata.secretKeys?.length}
|
||||
{#if metadata.type === 'stack' && metadata.stack?.secretKeys?.length}
|
||||
<section class="space-y-2 border-t pt-4">
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><KeyRound class="w-3.5 h-3.5" />Secrets ({metadata.secretKeys.length})</h4>
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><KeyRound class="w-3.5 h-3.5" />Secrets ({metadata.stack.secretKeys.length})</h4>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each metadata.secretKeys as key}
|
||||
{#each metadata.stack.secretKeys as key}
|
||||
<code class="rounded bg-muted px-1.5 py-0.5 text-xs">{key}</code>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user