diff --git a/Dockerfile b/Dockerfile index 6558613..25e9a94 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") " - busybox" \ " - tzdata" \ " - docker-cli" \ - " - docker-compose=5.2.0-r0" \ + " - docker-compose=5.3.1-r1" \ " - docker-cli-buildx" \ " - sqlite" \ " - postgresql-client" \ @@ -47,6 +47,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") " - curl" \ " - tini" \ " - su-exec" \ + " - restic" \ " - glibc" \ " - libstdc++" \ "entrypoint:" \ @@ -93,7 +94,7 @@ RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \ && rm -rf node_modules/@types /tmp/better-sqlite3-build # Build Go collector -FROM --platform=$BUILDPLATFORM golang:1.25.11 AS go-builder +FROM --platform=$BUILDPLATFORM golang:1.25.12 AS go-builder ARG TARGETARCH WORKDIR /app COPY collector/ ./collector/ diff --git a/VERSION b/VERSION index 4f312a0..c78fa8e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.37 +v1.0.38 diff --git a/collector/go.mod b/collector/go.mod index 1953331..7bd833b 100644 --- a/collector/go.mod +++ b/collector/go.mod @@ -1,3 +1,3 @@ module github.com/Finsys/dockhand/collector -go 1.25.11 +go 1.25.12 diff --git a/collector/main.go b/collector/main.go index d00b829..afafd09 100644 --- a/collector/main.go +++ b/collector/main.go @@ -552,6 +552,14 @@ func (m *manager) collectMetrics(env *environment) { } normalizedCPU := totalCPU / float64(cpuCount) + // Host-normalized CPU can never legitimately exceed 100% of total host + // capacity. Values >100 are a transient artifact — a freshly started + // container's first stats sample has precpu_stats≈0, so cpuDelta is its + // whole lifetime usage, producing a garbage spike. Clamp so the artifact + // never enters history and blows out the dashboard chart (#1279). + if normalizedCPU > 100 { + normalizedCPU = 100 + } var memPct float64 if memTotal > 0 { memPct = (float64(totalMem) / float64(memTotal)) * 100 diff --git a/drizzle-pg/0009_add_backup_tables.sql b/drizzle-pg/0009_add_backup_tables.sql new file mode 100644 index 0000000..c8ac31b --- /dev/null +++ b/drizzle-pg/0009_add_backup_tables.sql @@ -0,0 +1,48 @@ +CREATE TABLE IF NOT EXISTS "backup_destinations" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "repository" text NOT NULL, + "password" text NOT NULL, + "env_vars" text, + "flags" text, + "host_path" text, + "policies" text, + "last_test_at" timestamp, + "last_test_status" text, + "last_test_error" text, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now(), + CONSTRAINT "backup_destinations_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "backup_configs" ( + "id" serial PRIMARY KEY NOT NULL, + "type" text DEFAULT 'container' NOT NULL, + "target_name" text NOT NULL, + "environment_id" integer, + "destination_id" integer NOT NULL, + "enabled" boolean DEFAULT true, + "all_volumes" boolean DEFAULT true, + "selected_volumes" text, + "stop_before_backup" boolean DEFAULT false, + "schedule" text, + "retention" text, + "options" text, + "tags" text, + "last_backup_at" timestamp, + "last_backup_status" text, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "backup_configs" ADD CONSTRAINT "backup_configs_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "backup_configs" ADD CONSTRAINT "backup_configs_destination_id_backup_destinations_id_fk" FOREIGN KEY ("destination_id") REFERENCES "public"."backup_destinations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json index db16bdf..70549cf 100644 --- a/drizzle-pg/meta/_journal.json +++ b/drizzle-pg/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1781620381909, "tag": "0008_add_template_sources", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1781620382909, + "tag": "0009_add_backup_tables", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/0009_add_backup_tables.sql b/drizzle/0009_add_backup_tables.sql new file mode 100644 index 0000000..f40e493 --- /dev/null +++ b/drizzle/0009_add_backup_tables.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS `backup_configs` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `type` text DEFAULT 'container' NOT NULL, + `target_name` text NOT NULL, + `environment_id` integer, + `destination_id` integer NOT NULL, + `enabled` integer DEFAULT true, + `all_volumes` integer DEFAULT true, + `selected_volumes` text, + `stop_before_backup` integer DEFAULT false, + `schedule` text, + `retention` text, + `options` text, + `tags` text, + `last_backup_at` text, + `last_backup_status` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP, + `updated_at` text DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`destination_id`) REFERENCES `backup_destinations`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `backup_destinations` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `repository` text NOT NULL, + `password` text NOT NULL, + `env_vars` text, + `flags` text, + `host_path` text, + `policies` text, + `last_test_at` text, + `last_test_status` text, + `last_test_error` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP, + `updated_at` text DEFAULT CURRENT_TIMESTAMP +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `backup_destinations_name_unique` ON `backup_destinations` (`name`); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0145fa9..0984e55 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1781620376161, "tag": "0008_add_template_sources", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1781620377161, + "tag": "0009_add_backup_tables", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 3600beb..7bac6cd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.37", + "version": "1.0.38", "type": "module", "scripts": { "dev": "npx vite dev", @@ -80,7 +80,7 @@ "devalue": "5.8.1", "drizzle-orm": "0.45.2", "fast-xml-parser": "5.7.3", - "js-yaml": "4.2.0", + "js-yaml": "4.3.0", "ldapts": "8.1.3", "nodemailer": "9.0.1", "otpauth": "9.4.1", @@ -111,8 +111,6 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", - "ajv": "^8.20.0", - "ajv-draft-04": "^1.0.0", "autoprefixer": "^10.4.23", "bits-ui": "2.15.4", "clsx": "^2.1.1", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index b1cc976..126234a 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -19,6 +19,7 @@ import type { HandleServerError, Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; import { startRssTracker, stopRssTracker, rssBeforeOp, rssAfterOp } from '$lib/server/rss-tracker'; import { getClientIp } from '$lib/server/client-ip'; +import { BACKUPS_ENABLED } from '$lib/server/features'; // Side-effect import: installs globalThis.__authenticateWsUpgrade and // globalThis.__canAccessEnvForUser used by the raw WS upgrade handlers in // server.js / vite.config.ts to authenticate /api/containers/*/exec. @@ -144,9 +145,12 @@ if (!initialized) { setServerStartTime(); // Track when server started initDatabase(); - // Migrate plain text credentials to encrypted storage - // This also handles key rotation if ENCRYPTION_KEY env var differs from key file - migrateCredentials().catch(err => { + // Migrate plain text credentials to encrypted storage. + // This also handles key rotation if ENCRYPTION_KEY env var differs from the + // key file. Rotation flips the in-memory key while re-encrypting rows, so + // anything that decrypts credentials (the scheduler's backups, subprocesses) + // must not run until this settles — gate them on this promise below. + const credentialsReady = migrateCredentials().catch(err => { console.error('[Startup] Failed to migrate credentials:', err); }); @@ -166,15 +170,19 @@ if (!initialized) { }).catch(err => { console.error('[Startup] Failed to detect host data directory:', err); }); - // Cleanup orphaned scanner containers from previous runs (non-blocking) + // Cleanup orphaned scanner containers from previous runs (non-blocking). cleanupOrphanedScannerContainers().catch(err => { console.error('Failed to cleanup orphaned scanner containers:', err); }); - // Start background subprocesses for metrics and event collection (worker thread) - startSubprocesses().catch(err => { - console.error('Failed to start background subprocesses:', err); + // Start background subprocesses and the scheduler only AFTER credential + // migration/rotation has settled, so a scheduled backup can't decrypt a + // destination password mid-rotation (new key against old-key ciphertext). + credentialsReady.then(() => { + startSubprocesses().catch(err => { + console.error('Failed to start background subprocesses:', err); + }); + startScheduler(); // Start unified scheduler for auto-updates and git syncs (async) }); - startScheduler(); // Start unified scheduler for auto-updates and git syncs (async) startRssTracker(); // Start RSS memory tracking (no-op unless MEMORY_MONITOR=true) // Check license expiry on startup and then daily (with HMR guard) @@ -291,6 +299,17 @@ export const handle: Handle = async ({ event, resolve }) => { return resolve(event); } + // BETA GATE — REMOVE WHEN BACKUPS GOES GA (see $lib/server/features.ts). + // When FEAT_BACKUPS_ENABLED is off, the backup page and every /api/backup/* + // endpoint 404 as if they don't exist. Single chokepoint so no endpoint can + // leak; delete this block to remove the gate. + if (!BACKUPS_ENABLED) { + const p = event.url.pathname; + if (p === '/backups' || p.startsWith('/backups/') || p.startsWith('/api/backup/')) { + return new Response('Not found', { status: 404 }); + } + } + const httpBefore = rssBeforeOp(); try { // Check if auth is enabled diff --git a/src/lib/components/CheckUpdatesButton.svelte b/src/lib/components/CheckUpdatesButton.svelte index d734fc6..51a2cee 100644 --- a/src/lib/components/CheckUpdatesButton.svelte +++ b/src/lib/components/CheckUpdatesButton.svelte @@ -13,6 +13,7 @@ } export interface FailedCheckItem { + containerId: string; containerName: string; imageName: string; error: string; @@ -117,7 +118,7 @@ .map((r: any) => ({ containerId: r.containerId, containerName: r.containerName, imageName: r.imageName })); const failed: FailedCheckItem[] = data.results .filter((r: any) => r.error && !r.hasUpdate) - .map((r: any) => ({ containerName: r.containerName, imageName: r.imageName, error: r.error })); + .map((r: any) => ({ containerId: r.containerId, containerName: r.containerName, imageName: r.imageName, error: r.error })); if (withUpdates.length === 0) { // Keep the "Latest" status until re-check / env-switch — don't auto-revert (#1019) diff --git a/src/lib/components/ExecutionHistoryList.svelte b/src/lib/components/ExecutionHistoryList.svelte new file mode 100644 index 0000000..4b664bc --- /dev/null +++ b/src/lib/components/ExecutionHistoryList.svelte @@ -0,0 +1,164 @@ + + +{#if loading && executions.length === 0} +
+{:else if executions.length === 0} +
+ +

No backup runs yet.

+

Run a backup — its history appears here.

+
+{:else} +
+ + + + + + + + + {#if showRepo}{/if} + + + + + {#each executions as exec} + {@const badge = statusBadge(exec.status)} + {@const BadgeIcon = badge.icon} + {@const TrigIcon = triggerIcon(exec.triggeredBy)} + {@const isFail = exec.status === 'failed'} + {@const isOpen = expanded.has(exec.id)} + + + + + + + {#if showRepo} + {@const RepoIcon = getRepoTypeIcon(exec._repository || "")} + + {/if} + + + {#if isFail && isOpen && exec.errorMessage} + + + + {/if} + {/each} + +
RunTriggerDurationStatusDetailRepo
{exec.triggeredAt ? formatDateTime(exec.triggeredAt, true) : '—'} + + + + {formatDuration(exec.duration)} + + + + + {#if exec.errorMessage} + {cleanError(exec.errorMessage)} + {:else if exec.status === 'success' && exec.details?.dataAdded !== undefined} + {exec.details.filesNew ?? 0} new · {exec.details.filesChanged ?? 0} changed · {formatBytes(exec.details.dataAdded ?? 0)} + {:else} + — + {/if} + + {#if exec._repository || exec._destinationName} + {exec._destinationName || exec._repository} + {:else} + — + {/if} + +
+ {#if onViewLog} + + {/if} + {#if isFail && exec.errorMessage} + + {/if} +
+
+
+
+ Backup failed +
+
{exec.errorMessage}
+
+
+
+{/if} diff --git a/src/lib/components/ExecutionLogViewer.svelte b/src/lib/components/ExecutionLogViewer.svelte index 7a389fb..125afe6 100644 --- a/src/lib/components/ExecutionLogViewer.svelte +++ b/src/lib/components/ExecutionLogViewer.svelte @@ -21,7 +21,7 @@ type = 'trivy'; } else if (content.startsWith('[grype]')) { type = 'grype'; - } else if (content.toLowerCase().includes('error')) { + } else if (/\berrors?\b/i.test(content) && !/\bno\s+errors?\b/i.test(content)) { type = 'error'; } diff --git a/src/lib/components/LogConsole.svelte b/src/lib/components/LogConsole.svelte new file mode 100644 index 0000000..6183b6a --- /dev/null +++ b/src/lib/components/LogConsole.svelte @@ -0,0 +1,86 @@ + + +
+
+
+ + Output ({lines.length} {lines.length === 1 ? 'line' : 'lines'}) +
+ +
+
+ {#each lines as line} + {@const p = parse(line)} + {@const isError = p.source === 'error' || /error|fatal|fail/i.test(p.text)} + {@const vt = volToken(p.text)} +
+ {#if p.source} + {p.source} + {/if} + {#if vt} + {#if vt.kind === 'bind'} + bind + {:else} + vol + {/if} + {vt.rest} + {:else} + {p.text} + {/if} +
+ {/each} +
+
diff --git a/src/lib/components/ModalHeader.svelte b/src/lib/components/ModalHeader.svelte new file mode 100644 index 0000000..9f9ee4a --- /dev/null +++ b/src/lib/components/ModalHeader.svelte @@ -0,0 +1,48 @@ + + + + + {title} + {#if name} + + {name} +

{name}

+
+ {/if} + {#if env} + on {env} + {/if} + {#if extra} + {@render extra()} + {/if} +
diff --git a/src/lib/components/MountTypeBadge.svelte b/src/lib/components/MountTypeBadge.svelte new file mode 100644 index 0000000..43c1217 --- /dev/null +++ b/src/lib/components/MountTypeBadge.svelte @@ -0,0 +1,24 @@ + + +{#if isBind} + + bind +{:else} + + vol +{/if} diff --git a/src/lib/components/PullTab.svelte b/src/lib/components/PullTab.svelte index d52c941..167c5b5 100644 --- a/src/lib/components/PullTab.svelte +++ b/src/lib/components/PullTab.svelte @@ -8,8 +8,8 @@ import { CheckCircle2, XCircle, Loader2, AlertCircle, Terminal, Sun, Moon, Download } from 'lucide-svelte'; import { onMount } from 'svelte'; import { appendEnvParam } from '$lib/stores/environment'; - import { watchJob } from '$lib/utils/sse-fetch'; import { formatBytes } from '$lib/utils/format'; + import { watchJob } from '$lib/utils/sse-fetch'; interface LayerProgress { id: string; diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte index bb0e804..29f2e10 100644 --- a/src/lib/components/app-sidebar.svelte +++ b/src/lib/components/app-sidebar.svelte @@ -24,6 +24,7 @@ ClipboardList, Activity, Timer, + Archive, LibraryBig, CircleArrowUp, Pencil, @@ -89,6 +90,11 @@ * - ENTERPRISE edition: check if user has ANY permission for the resource */ function canSeeMenuItem(item: MenuItem): boolean { + // BETA GATE: hide Backups unless FEAT_BACKUPS_ENABLED is on (see features.ts) + if (item.href === '/backups' && !$page.data.backupsEnabled) { + return false; + } + // Enterprise-only items are hidden without enterprise license if (item.enterpriseOnly && !$licenseStore.isEnterprise) { return false; @@ -126,6 +132,7 @@ { href: '/templates', Icon: LibraryBig, label: 'Templates', permission: 'templates' }, { href: '/registry', Icon: Download, label: 'Registry', permission: 'registries' }, { href: '/activity', Icon: Activity, label: 'Activity', permission: 'activity' }, + { href: '/backups', Icon: Archive, label: 'Backups', permission: 'backups' }, { href: '/schedules', Icon: Timer, label: 'Schedules', permission: 'schedules' }, { href: '/audit', Icon: ClipboardList, label: 'Audit log', permission: 'audit_logs', enterpriseOnly: true }, { href: '/settings', Icon: Settings, label: 'Settings', permission: 'settings' } diff --git a/src/lib/components/backup/DestinationPicker.svelte b/src/lib/components/backup/DestinationPicker.svelte new file mode 100644 index 0000000..c08f12c --- /dev/null +++ b/src/lib/components/backup/DestinationPicker.svelte @@ -0,0 +1,82 @@ + + + { value = Number(v); }} +> + + {#if selected} + {@const SelIcon = getRepoTypeIcon(selected.repository)} + + + {selected.name} + + {:else} + {placeholder} + {/if} + + + {#each destinations as dest} + {@const DIcon = getRepoTypeIcon(dest.repository)} + {@const localOnRemote = disableLocalForRemoteEnv && isLocalRepo(dest.repository)} + {#if localOnRemote} + + + + {dest.name} + — local + + + {:else} + + + + {dest.name} + + + {/if} + {/each} + + diff --git a/src/lib/components/backup/VolumePicker.svelte b/src/lib/components/backup/VolumePicker.svelte new file mode 100644 index 0000000..17fe147 --- /dev/null +++ b/src/lib/components/backup/VolumePicker.svelte @@ -0,0 +1,93 @@ + + +{#if volumes.length > 0} + +
+
+ + +
+ + {#if showBindWarning && bindMounts.length > 0} +
+ +
+

{bindMounts.length} bind mount{bindMounts.length !== 1 ? 's' : ''} detected

+

Contents are captured in the snapshot, but bind sources are host paths. Restoring to a different environment requires those exact paths on the target host — otherwise services start with empty mounts.

+
+
+ {/if} + + +
+ {#each volumes as vol} + + {/each} +
+
+{:else} +

{emptyLabel}

+{/if} diff --git a/src/lib/components/cloud-icons/AmazonS3.svelte b/src/lib/components/cloud-icons/AmazonS3.svelte new file mode 100644 index 0000000..2bf64be --- /dev/null +++ b/src/lib/components/cloud-icons/AmazonS3.svelte @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/lib/components/cloud-icons/AzureBlob.svelte b/src/lib/components/cloud-icons/AzureBlob.svelte new file mode 100644 index 0000000..7c707a0 --- /dev/null +++ b/src/lib/components/cloud-icons/AzureBlob.svelte @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/src/lib/components/cloud-icons/Backblaze.svelte b/src/lib/components/cloud-icons/Backblaze.svelte new file mode 100644 index 0000000..7e8563f --- /dev/null +++ b/src/lib/components/cloud-icons/Backblaze.svelte @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/lib/components/cloud-icons/GoogleCloud.svelte b/src/lib/components/cloud-icons/GoogleCloud.svelte new file mode 100644 index 0000000..57b9796 --- /dev/null +++ b/src/lib/components/cloud-icons/GoogleCloud.svelte @@ -0,0 +1,7 @@ + + + + + diff --git a/src/lib/components/cloud-icons/RestServer.svelte b/src/lib/components/cloud-icons/RestServer.svelte new file mode 100644 index 0000000..e86d0ea --- /dev/null +++ b/src/lib/components/cloud-icons/RestServer.svelte @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/lib/components/cloud-icons/index.ts b/src/lib/components/cloud-icons/index.ts new file mode 100644 index 0000000..d5c6d31 --- /dev/null +++ b/src/lib/components/cloud-icons/index.ts @@ -0,0 +1,5 @@ +export { default as AmazonS3Icon } from './AmazonS3.svelte'; +export { default as BackblazeIcon } from './Backblaze.svelte'; +export { default as AzureBlobIcon } from './AzureBlob.svelte'; +export { default as GoogleCloudIcon } from './GoogleCloud.svelte'; +export { default as RestServerIcon } from './RestServer.svelte'; diff --git a/src/lib/components/cron-editor.svelte b/src/lib/components/cron-editor.svelte index 45e939d..f2f4bf2 100644 --- a/src/lib/components/cron-editor.svelte +++ b/src/lib/components/cron-editor.svelte @@ -10,11 +10,13 @@ interface Props { value: string; - onchange: (cron: string) => void; + onchange?: (cron: string) => void; disabled?: boolean; + compact?: boolean; + invalid?: boolean; } - let { value, onchange, disabled = false }: Props = $props(); + let { value = $bindable(), onchange, disabled = false, compact = false, invalid = $bindable(false) }: Props = $props(); // Detect schedule type from cron expression function detectScheduleType(cron: string): 'daily' | 'weekly' | 'custom' { @@ -86,7 +88,8 @@ return; } - onchange(newCron); + value = newCron; + onchange?.(newCron); } // Handle schedule type change @@ -106,12 +109,14 @@ if (type === 'daily') { minute = '0'; hour = '3'; - onchange('0 3 * * *'); + value = '0 3 * * *'; + onchange?.(value); } else if (type === 'weekly') { minute = '0'; hour = '3'; dayOfWeek = '1'; // Monday - onchange('0 3 * * 1'); + value = '0 3 * * 1'; + onchange?.(value); } previousScheduleType = type; } @@ -134,7 +139,8 @@ function handleCustomCronInput(e: Event) { const newValue = (e.currentTarget as HTMLInputElement).value; - onchange(newValue); + value = newValue; + onchange?.(newValue); } // Validate cron expression (supports 5-field and 6-field with seconds) @@ -148,6 +154,12 @@ return parts.every((part) => cronFieldPattern.test(part)); } + // Sync validity to bindable prop so parents can gate Save buttons + $effect(() => { + const next = !value || !value.trim() ? true : !isValidCron(value); + if (invalid !== next) invalid = next; + }); + // Human-readable description using cronstrue let humanReadable = $derived(() => { if (!value) return ''; @@ -202,10 +214,26 @@ ]; -
+{#if compact} + +
+ { value = e.currentTarget.value; onchange?.(e.currentTarget.value); }} + placeholder="0 2 * * *" + class="h-7 text-xs font-mono {humanReadable() === 'Invalid' ? 'border-destructive' : ''}" + {disabled} + /> + {#if value} + {@const readable = humanReadable()} +

{readable}

+ {/if} +
+{:else} +
- +
{#if scheduleType === 'daily'} @@ -245,7 +273,7 @@ at - + {hours.find((h: { value: string; label: string }) => h.value === hour)?.label || hour} @@ -255,7 +283,7 @@ - + {minutes.find(m => m.value === minute)?.label || `:${minute}`} @@ -268,7 +296,7 @@ {#if scheduleType === 'weekly'} on - + {daysOfWeek.find(d => d.value === dayOfWeek)?.label || dayOfWeek} @@ -293,13 +321,12 @@ {/if}
- -
- {#if value} - {@const readable = humanReadable()} - {@const isInvalid = readable === 'Invalid'} -

- {readable} -

- {/if} -
+ +{#if value} + {@const readable = humanReadable()} + {@const isInvalid = readable === 'Invalid'} +

+ {readable} +

+{/if} +{/if} diff --git a/src/lib/components/data-grid/DataGrid.svelte b/src/lib/components/data-grid/DataGrid.svelte index 6ad2919..a372d7e 100644 --- a/src/lib/components/data-grid/DataGrid.svelte +++ b/src/lib/components/data-grid/DataGrid.svelte @@ -764,6 +764,7 @@ {#if headerCell} {@render headerCell(colConfig, sortState)} diff --git a/src/lib/components/host-info.svelte b/src/lib/components/host-info.svelte index 365db2e..ae96f80 100644 --- a/src/lib/components/host-info.svelte +++ b/src/lib/components/host-info.svelte @@ -8,8 +8,8 @@ import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte'; import { toast } from 'svelte-sonner'; import { themeStore, type FontSize } from '$lib/stores/theme'; - import { getTimeFormat } from '$lib/stores/settings'; import { formatBytes } from '$lib/utils/format'; + import { getTimeFormat } from '$lib/stores/settings'; // Font size scaling for header let fontSize = $state('normal'); diff --git a/src/lib/components/icons/RotateCwFadingClock.svelte b/src/lib/components/icons/RotateCwFadingClock.svelte new file mode 100644 index 0000000..d07c76e --- /dev/null +++ b/src/lib/components/icons/RotateCwFadingClock.svelte @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + diff --git a/src/lib/components/ui/toggle-pill/toggle-pill.svelte b/src/lib/components/ui/toggle-pill/toggle-pill.svelte index 3069fe8..6784463 100644 --- a/src/lib/components/ui/toggle-pill/toggle-pill.svelte +++ b/src/lib/components/ui/toggle-pill/toggle-pill.svelte @@ -21,7 +21,7 @@ + +
+
+ + {#if !loading && configs.length > 0} +
+ {#if healthStats.healthy > 0} + {healthStats.healthy} healthy + {/if} + {#if healthStats.failed > 0} + {healthStats.failed} failed + {/if} + {#if healthStats.stale > 0} + {healthStats.stale} stale + {/if} + {#if healthStats.neverRun > 0} + {healthStats.neverRun} never run + {/if} + {#if healthStats.orphans > 0} + {healthStats.orphans} orphan{healthStats.orphans !== 1 ? 's' : ''} + {/if} + · + {healthStats.scheduled} scheduled +
+ {/if} + +
+ {#if configs.length === 0 && orphanTargets.length === 0 && !loading && !snapshotCountsLoading} +
+ +

No backups configured

+

Configure backups on individual containers or stacks via their edit modal.

+
+ {:else} + { sortField = state.field; sortDirection = state.direction; }} + bind:expandedKeys={expandedKeys} + onExpandChange={handleExpandChange} + onRowClick={(config) => toggleExpand(config)} + class="border-none" + wrapperClass="border rounded-lg" + > + {#snippet cell(column, config, rowState)} + {#if column.id === 'expand'} + + {:else if column.id === 'name'} + {config.targetName} + {:else if column.id === 'type'} +
+ {#if config.type === 'container'} + Container backup + {:else} + Stack backup + {/if} +
+ {:else if column.id === 'environment'} + {#if config.environmentName} +
+ + {config.environmentName} +
+ {:else} + — + {/if} + {:else if column.id === 'repository'} + {#if config.destinationName && config.destinationRepository} + {@const DestIcon = getDestIcon(config.destinationRepository)} + + +
+ + {config.destinationName} +
+
+ {config.destinationRepository} +
+ {:else} + — + {/if} + {:else if column.id === 'snapshots'} + {@const asyncCount = snapshotCounts.get(config.key)} +
+ {#if asyncCount !== undefined} + {asyncCount} + {:else if snapshotCountsLoading} + + {:else} + 0 + {/if} +
+ {:else if column.id === 'lastBackup'} + {#if config.lastBackupAt} + {formatDateTime(config.lastBackupAt)} + {:else} + Never + {/if} + {:else if column.id === 'retention'} + {@const summary = retentionSummary(config.retention)} + {#if summary} + {summary} + {:else} + — + {/if} + {:else if column.id === 'schedule'} + {#if config.isOrphan} + no schedule + {:else if config.schedule} + {formatCron(config.schedule)} + {:else} + — + {/if} + {:else if column.id === 'status'} +
+ {#if config.isOrphan} + No schedule — snapshots found in repository + {:else if runningBackup === config.id} + Backup in progress + {:else if config.lastBackupStatus === 'success'} + Last backup succeeded + {:else if config.lastBackupStatus === 'failed'} + Last backup failed + {:else} + No backup run yet + {/if} +
+ {:else if column.id === 'actions'} + + +
e.stopPropagation()}> + {#if !config.isOrphan} + {#if config.schedule} + + {/if} + + deleteConfig(config)} + onOpenChange={(open) => confirmDeleteConfig = open ? config.id : null} + > + {#snippet children({ open })} + {#if deletingConfig === config.id} + + {:else} + + {/if} + {/snippet} + + {/if} +
+ {/if} + {/snippet} + + {#snippet expandedRow(config, rowState)} + {@const key = config.key} + {@const snapshots = snapshotsMap.get(key) || []} + {@const isLoading = loadingSnapshots.has(key)} +
+ {#if lastError?.configId === config.id} +
+ + {cleanErrorMessage(lastError.message)} + +
+ {/if} +
+

Snapshots

+ + {#if !isLoading && snapshots.length > 0}{snapshots.length}{/if} + +
+ {#if isLoading && snapshots.length === 0} + +
+ + Loading snapshots… +
+ {:else if snapshots.length === 0} +

No snapshots yet. Run a backup to create one.

+ {:else} +
+ + + + + + + + + + + + {#each snapshots.slice(0, snapshotLimits.get(key) || SNAPSHOT_PAGE_SIZE) as snapshot} + {@const stats = snapshotStats.get(snapshot.id)} + {@const isDiffPending = diffPending?.snapshot.id === snapshot.id} + + + + + + + + {/each} + {#if snapshots.length > (snapshotLimits.get(key) || SNAPSHOT_PAGE_SIZE)} + + + + {/if} + +
IDCreatedStatsRepoActions
{snapshot.shortId}{formatDateTime(snapshot.time)} + {#if stats} + {stats.filesNew} new, {stats.filesChanged} changed · {formatBytes(stats.dataAdded)} + {:else} + — + {/if} + + {#if snapshot._destinationName} + + + {snapshot._destinationName} + + {/if} + +
+ {#if snapshots.length >= 2} + + {/if} + + + deleteSnapshot(config, snapshot)} + onOpenChange={(open) => confirmDeleteSnapshot = open ? snapshot.id : null} + > + {#snippet children({ open })} + {#if deletingSnapshot === snapshot.id} + + {:else} + + {/if} + {/snippet} + +
+
+ +
+
+ {/if} +
+ {/snippet} +
+ {/if} +
+ + + + + + + + + + + + + diff --git a/src/routes/backups/BackupLogModal.svelte b/src/routes/backups/BackupLogModal.svelte new file mode 100644 index 0000000..3eb68be --- /dev/null +++ b/src/routes/backups/BackupLogModal.svelte @@ -0,0 +1,89 @@ + + + + e.preventDefault()}> + + + + {title} + {#if status === 'running'} + Running + {:else if status === 'success'} + Completed + {:else} + Failed + {/if} + + + + {#if status === 'running' && progress > 0} +
+
+
+ {/if} + +
+ + + {#if status === 'running' && logs.length === 0} +
+ Waiting for output… +
+ {/if} + {#if error}
{error}
{/if} + + {#if logs.length > 0} + + {/if} +
+ + + {#if status === 'running' && onStop} + + {/if} + + +
+
diff --git a/src/routes/backups/CreateBackupModal.svelte b/src/routes/backups/CreateBackupModal.svelte new file mode 100644 index 0000000..ee73687 --- /dev/null +++ b/src/routes/backups/CreateBackupModal.svelte @@ -0,0 +1,435 @@ + + + { if (!isOpen && running) return; open = isOpen; }}> + + + + Create backup + + {#if selectedItem} + + {#if selectedItem.type === 'container'}{:else}{/if} + {selectedItem.name} + on + + {selectedItem.envName} + {#if selectedDest} + → + + {selectedDest.name} + {/if} + + {/if} + + + +
+ + + + + +
+ + + {#if step === 1} + +
+ { selectedEnvId = Number(v); selectedItem = null; fetchContainers(Number(v)); }}> + + {@const env = environments.find(e => e.id === selectedEnvId)} + {#if env} + {env.name} + {:else}Select environment{/if} + + + {#each environments as env} + + {env.name} + + {/each} + + + {#if selectedEnvId} +
+ + +
+ {/if} +
+ {/if} +
+ {#if step === 1} + +
+ + {#if loadingContainers} +
+ {:else if selectedEnvId && filteredContainers.length > 0} +
+ {#each filteredContainers as item} + + {/each} +
+ {:else if selectedEnvId} +

No containers or stacks found

+ {:else} +

Select an environment to see available sources

+ {/if} +
+ + {:else if step === 2} + +
+
+ + +
+ +
+ +
+ +

Ensures data consistency (will restart after)

+
+
+ + {#if selectedItem} + + {/if} + +
+ +
+
+ + {:else if step === 3} + +
+
+ {#if allVolumes} + All volumes + {:else} + {selectedVolumes.length} volume{selectedVolumes.length !== 1 ? 's' : ''} + {/if} + {#if stopBeforeBackup} + · + stop during backup + {/if} +
+ +
+ { saveSchedule = !saveSchedule; }} /> + Save as recurring schedule +
+ + {#if saveSchedule} +
+ +
+ {:else} +

+ Runs once now. The backup stays on the list so you can re-run or remove it later — it just won't run on a schedule. +

+ {/if} + + {#if backupStatus === 'idle'} + + {/if} + +
+ {#if backupStatus === 'idle' || (backupStatus !== 'running' && logs.length === 0)} + + {/if} +
+ {#if backupStatus === 'success'} + + {:else if backupStatus === 'error' && !running} + + + {:else} + + {/if} +
+ + + {#if logs.length > 0 || running} +
+ + {#if backupStatus === 'running'} +
+ Backing up… +
+ {:else if backupStatus === 'success'} +
+ Backup completed +
+ {:else if backupStatus === 'error'} +
+ {backupError || 'Failed'} +
+ {/if} +
+ {/if} +
+ {/if} +
+
+
diff --git a/src/routes/backups/SnapshotDiffModal.svelte b/src/routes/backups/SnapshotDiffModal.svelte new file mode 100644 index 0000000..085896d --- /dev/null +++ b/src/routes/backups/SnapshotDiffModal.svelte @@ -0,0 +1,220 @@ + + + + + + Snapshot comparison + + {snapshotA.shortId} + ({formatDateTime(snapshotA.time)}) + + {snapshotB.shortId} + ({formatDateTime(snapshotB.time)}) + + + + +
+ + +
+ +
+ {#if activeTab === 'files'} + {#if loading} +
+ + Comparing snapshots... +
+ {:else if error} +
{error}
+ {:else if diff} +
+ {#if diff.added.length > 0} + {diff.added.length} added + {/if} + {#if diff.removed.length > 0} + {diff.removed.length} removed + {/if} + {#if diff.modified.length > 0} + {diff.modified.length} modified + {/if} + {#if diff.metadataChanged.length > 0} + {diff.metadataChanged.length} metadata + {/if} + {#if totalChanges === 0} + No differences found + {/if} +
+ +
+ {#each diff.added as path} +
+ {path}
+ {/each} + {#each diff.removed as path} +
- {path}
+ {/each} + {#each diff.modified as path} +
M {path}
+ {/each} + {#each diff.metadataChanged as path} +
U {path}
+ {/each} +
+ {/if} + {:else if activeTab === 'metadata'} + {#if metaLoading} +
+ + Loading metadata... +
+ {:else if !metaA || !metaB} +

Metadata not available for one or both snapshots.

+ {:else if metaDiffs.length === 0} +

The container configuration is identical (image, command, env, ports, mounts, networks, …).

+

If the Files tab shows metadata.json as modified, that's the capture timestamp / recorded container state inside it changing between backups — not a config change.

+ {:else} + + + + + + + + + + {#each metaDiffs as d} + + + + + + {/each} + +
Field{snapshotA.shortId}{snapshotB.shortId}
{d.field} +
{d.valueA}
+
+
{d.valueB}
+
+ {/if} + {/if} +
+
+
diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte index b2f8542..a03df25 100644 --- a/src/routes/containers/+page.svelte +++ b/src/routes/containers/+page.svelte @@ -13,7 +13,7 @@ import * as Tooltip from '$lib/components/ui/tooltip'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import { formatPorts, formatExposedPorts } from '$lib/utils/port-format'; - import { formatBytes } from '$lib/utils/format'; + import { formatBytes, formatBytesCompact } from '$lib/utils/format'; import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import { Badge } from '$lib/components/ui/badge'; @@ -306,6 +306,10 @@ // Set of container IDs with updates available (for O(1) lookup) const containersWithUpdatesSet = $derived(new Set(batchUpdateContainerIds)); + // Container IDs whose last update check failed (e.g. registry rate-limited) — #1255 + const containersWithFailedCheckSet = $derived(new Set($containerStore.failedUpdateIds)); + const failedUpdateErrors = $derived($containerStore.failedUpdateErrors); + // Filter dropdown entries: real statuses plus the synthetic // "update-available" entry, only offered once we know about a pending // update — picking it on an empty set would just empty the list (#1063). @@ -460,6 +464,7 @@ function handleUpdateCheckComplete(result: { withUpdates: Array<{ containerId: string; containerName: string }>; + failed?: Array<{ containerId: string; error: string }>; }) { if (result.withUpdates.length === 0) { containerStore.setPendingUpdates([], new Map()); @@ -470,6 +475,13 @@ new Map(result.withUpdates.map((r) => [r.containerId, r.containerName])) ); } + // Record failed checks so their containers show a "check failed" state + // instead of masquerading as up to date (#1255). + const failed = result.failed ?? []; + containerStore.setFailedUpdates( + failed.map((f) => f.containerId), + new Map(failed.map((f) => [f.containerId, f.error])) + ); } // Load pending updates from database (persisted from check-updates or scheduled jobs) @@ -505,6 +517,9 @@ const response = await fetch(`/api/containers/pending-updates?env=${envId}`, { method: 'DELETE' }); if (response.ok) { containerStore.setPendingUpdates([], new Map()); + // Failed-check state is session-only — clear it here too so "Clear" + // dismisses the red "check failed" icons alongside the amber ones. + containerStore.setFailedUpdates([], new Map()); } } catch { toast.error('Failed to clear update indicators'); @@ -1746,6 +1761,22 @@ {/if} {/if} + {:else if containersWithFailedCheckSet.has(container.id)} + + + + + +
+

+ + Update check failed +

+

{failedUpdateErrors.get(container.id) ?? 'Could not query registry'}

+

Update status unknown — often a Docker Hub rate limit. Try again later.

+
+
+
{/if} {container.image} @@ -1797,7 +1828,7 @@ {@const memoryTooltip = stats.memoryCache > 0 ? `${formatBytes(stats.memoryUsage)} / ${formatBytes(stats.memoryLimit)} (Total: ${formatBytes(stats.memoryRaw)} | Cache: ${formatBytes(stats.memoryCache)})` : `${formatBytes(stats.memoryUsage)} / ${formatBytes(stats.memoryLimit)}`} - {formatBytes(stats.memoryUsage)}/{formatBytes(stats.memoryLimit, 0)} + {formatBytesCompact(stats.memoryUsage)}/{formatBytesCompact(stats.memoryLimit, 0)} {:else if container.state === 'running'} ... {:else} @@ -1809,7 +1840,7 @@ {#if containerStats.get(container.id)} {@const stats = containerStats.get(container.id)} - ↓{formatBytes(stats.networkRx, 0)} ↑{formatBytes(stats.networkTx, 0)} + ↓{formatBytesCompact(stats.networkRx, 0)} ↑{formatBytesCompact(stats.networkTx, 0)} {:else if container.state === 'running'} ... @@ -1822,7 +1853,7 @@ {#if containerStats.get(container.id)} {@const stats = containerStats.get(container.id)} - r{formatBytes(stats.blockRead, 0)} w{formatBytes(stats.blockWrite, 0)} + r{formatBytesCompact(stats.blockRead, 0)} w{formatBytesCompact(stats.blockWrite, 0)} {:else if container.state === 'running'} ... diff --git a/src/routes/containers/BackupPanel.svelte b/src/routes/containers/BackupPanel.svelte new file mode 100644 index 0000000..4d0ef3a --- /dev/null +++ b/src/routes/containers/BackupPanel.svelte @@ -0,0 +1,824 @@ + + + +
+ + + +
+ + +
+ 0} + configIds={configs.map((c) => c.id)} + onCount={(n) => (snapshotCount = n)} + onLoadingChange={(l) => (snapshotsLoading = l)} + /> +
+ +{#if subTab === 'snapshots'} + +{:else if subTab === 'history'} + +{:else if loading} +
+ +
+{:else} +
+ + {#each configs as cfg} + {@const dest = destinations.find(d => d.id === cfg.destinationId)} + {@const Icon = dest ? getTypeIcon(dest.repository) : HardDrive} +
+
+
+ + {dest?.name || 'Unknown'} + {#if !cfg.enabled}Paused{/if} +
+
+ {cfg.schedule ? formatCron(cfg.schedule) : 'Manual'} · {volumeSummary(cfg)}{cfgRetentionSummary(cfg) ? ` · ${cfgRetentionSummary(cfg)}` : ''} + {#if cfg.lastBackupAt} + · + {#if cfg.lastBackupStatus === 'success'}{:else if cfg.lastBackupStatus === 'failed' || cfg.lastBackupStatus === 'error'}{/if} + {formatDateTime(cfg.lastBackupAt)} + {/if} +
+
+
+ {#if cfg.schedule} + + {/if} + + + deleteConfig(cfg.id)} + onOpenChange={(open) => confirmDeleteId = open ? cfg.id : null} + > + {#snippet children({ open })} + + {/snippet} + +
+
+ {/each} + + {#if configs.length === 0 && !isNew && !editingConfig} +

No backup schedules configured for this {type}.

+ {/if} + + + {#if isNew || editingConfig} +
+
+ {isNew ? 'New backup schedule' : 'Edit schedule'} + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ + + + + + {#if showAdvanced} +
+ +
+ +

Older snapshots are pruned after each backup. Set 0 to disable.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +

Comma-separated glob patterns to exclude from backup.

+
+ +
+
+ + +
+

+ Skips folders containing a CACHEDIR.TAG marker file + — used by npm, pip, Cargo, browsers, and many other tools to tag + regenerable cache content. Saves significant backup space; the contents + can always be rebuilt from source. Turn off only if you have a specific + reason to back up cache data. +

+
+ +
+
+ + { editCompression = v; }}> + {editCompression} + + Auto + Off + Max + + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ + +
+

POST with a JSON payload sent after backup completes (falls back to GET for simple receivers). Use for healthchecks, Slack, etc.

+
+
+ {/if} + + +
+ + {#if editingConfig} + + {:else} + + + + {/if} +
+
+ {/if} + + + {#if configs.length > 0 && !isNew && !editingConfig} + + {/if} + + + {#if configs.length === 0 && !isNew && !editingConfig} + + {/if} +
+{/if} + + + + + + + + {containerName} + {#if progressEnv} + on + {progressEnv.name} + {/if} + {#if progressDest} + → + {@const RepoIcon = getRepoTypeIcon(progressDest.repository)} + {progressDest.name} + {/if} + + Live backup progress for {containerName}. + + + +
+ {#if progressStatus === 'running'} + Backing up… + {:else if progressStatus === 'success'} + Backup completed + {:else} + {progressError || 'Backup failed'} + {/if} +
+ + {#if progressStatus === 'running'} + + {:else} + + {/if} + +
+
+ + + + + + Backup log + +
+ {#if logDialogLoading} +
+ + Loading log… +
+ {:else if logDialogContent} + + {:else} +

No log output was recorded for this run.

+ {/if} +
+
+
diff --git a/src/routes/containers/ContainerInspectModal.svelte b/src/routes/containers/ContainerInspectModal.svelte index f60db28..6b9c7c9 100644 --- a/src/routes/containers/ContainerInspectModal.svelte +++ b/src/routes/containers/ContainerInspectModal.svelte @@ -5,7 +5,7 @@ import * as Tabs from '$lib/components/ui/tabs'; import { Button } from '$lib/components/ui/button'; import { Badge } from '$lib/components/ui/badge'; - import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe, Link, Unlink, Play, Square as SquareIcon, RotateCw, Trash2 } from 'lucide-svelte'; + import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, Folder, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe, Link, Unlink, Play, Square as SquareIcon, RotateCw, Trash2 } from 'lucide-svelte'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import * as Select from '$lib/components/ui/select'; import { toast } from 'svelte-sonner'; @@ -677,7 +677,7 @@ {:else} - {displayName || containerId.slice(0, 12)} + {displayName || containerId.slice(0, 12)} {/if} + {#if $currentEnvironment} + on {$currentEnvironment.name} + {/if} {@const composeStack = containerData?.Config?.Labels?.['com.docker.compose.project']} {#if composeStack && !loading} @@ -1336,7 +1339,15 @@ {#each containerData.Mounts as mount}
- {mount.Type} + + {#if mount.Type === 'bind'} + bind + {:else if mount.Type === 'volume'} + volume + {:else} + {mount.Type} + {/if} {mount.RW ? 'Read/Write' : 'Read-Only'} @@ -1399,7 +1410,7 @@
{containerData.divergence.env.length} env var{containerData.divergence.env.length === 1 ? '' : 's'} differ from the image: {containerData.divergence.env.join(', ')}. - Values set by you at create time will stay. To reset to the image's current values, Remove & Deploy. + On the next update, image-provided values you haven't overridden are refreshed to the new image; values you set yourself are kept. Use Remove & Deploy to reset everything to the image.
{/if} @@ -1429,7 +1440,7 @@
{containerData.divergence.labels.length} label{containerData.divergence.labels.length === 1 ? '' : 's'} differ from the image: {containerData.divergence.labels.join(', ')}. - Values set by you at create time will stay. To reset to the image's current values, Remove & Deploy. + On the next update, image-provided values you haven't overridden are refreshed to the new image; values you set yourself are kept. Use Remove & Deploy to reset everything to the image.
{/if} diff --git a/src/routes/containers/CreateContainerModal.svelte b/src/routes/containers/CreateContainerModal.svelte index 04a424c..c97d582 100644 --- a/src/routes/containers/CreateContainerModal.svelte +++ b/src/routes/containers/CreateContainerModal.svelte @@ -14,37 +14,7 @@ import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte'; // Parse shell command respecting quotes - function parseShellCommand(cmd: string): string[] { - const args: string[] = []; - let current = ''; - let inQuotes = false; - let quoteChar = ''; - - for (let i = 0; i < cmd.length; i++) { - const char = cmd[i]; - - if ((char === '"' || char === "'") && !inQuotes) { - inQuotes = true; - quoteChar = char; - } else if (char === quoteChar && inQuotes) { - inQuotes = false; - quoteChar = ''; - } else if (char === ' ' && !inQuotes) { - if (current) { - args.push(current); - current = ''; - } - } else { - current += char; - } - } - - if (current) { - args.push(current); - } - - return args; - } + import { parseShellCommand } from '$lib/utils/shell'; interface ConfigSet { id: number; @@ -608,7 +578,7 @@ Create new container {#if $currentEnvironment} - on {$currentEnvironment.name} + on {$currentEnvironment.name} {/if} {:else if name} - - {name} + - + {name} + {#if $currentEnvironment} + on {$currentEnvironment.name} + {/if} {/if} @@ -1093,7 +1167,42 @@ Loading container data... {:else} -
+
+ + + {#if $page.data.backupsEnabled} + + {/if} +
+ +
@@ -1193,3 +1303,23 @@ {/if} + + + + + + Unsaved changes + + You have unsaved changes. Are you sure you want to close without saving? + + +
+ + +
+
+
diff --git a/src/routes/containers/FileBrowserModal.svelte b/src/routes/containers/FileBrowserModal.svelte index 872d9a9..c88cefe 100644 --- a/src/routes/containers/FileBrowserModal.svelte +++ b/src/routes/containers/FileBrowserModal.svelte @@ -2,6 +2,7 @@ import * as Dialog from '$lib/components/ui/dialog'; import { FolderOpen } from 'lucide-svelte'; import FileBrowserPanel from './FileBrowserPanel.svelte'; + import ModalHeader from '$lib/components/ModalHeader.svelte'; import { canAccess } from '$lib/stores/auth'; interface Props { @@ -22,12 +23,9 @@ - + e.preventDefault()}> - - - Browse files - {containerName} - + Browse, upload, and download files from the container filesystem. diff --git a/src/routes/containers/FileBrowserPanel.svelte b/src/routes/containers/FileBrowserPanel.svelte index 00b0e40..3e23c08 100644 --- a/src/routes/containers/FileBrowserPanel.svelte +++ b/src/routes/containers/FileBrowserPanel.svelte @@ -52,7 +52,7 @@ readonly?: boolean; } - type BrowserMode = 'container' | 'volume'; + type BrowserMode = 'container' | 'volume' | 'snapshot'; type SortField = 'name' | 'size' | 'modified' | 'type'; type SortDirection = 'asc' | 'desc'; @@ -75,6 +75,9 @@ selectFilter?: RegExp; // Callback when a file is selected (in selectMode) onFileSelect?: (path: string, name: string) => void; + // Snapshot browsing mode + snapshotId?: string; + destinationId?: number; } let { @@ -86,7 +89,9 @@ onUsageChange, selectMode = false, selectFilter, - onFileSelect + onFileSelect, + snapshotId, + destinationId }: Props = $props(); // For volume mode, track whether volume is in use (controls editing ability) @@ -96,12 +101,13 @@ let volumeHelperId = $state(null); // Determine mode based on which prop is provided - const mode: BrowserMode = $derived(volumeName ? 'volume' : 'container'); + const mode: BrowserMode = $derived(snapshotId ? 'snapshot' : volumeName ? 'volume' : 'container'); const isVolumeMode = $derived(mode === 'volume'); + const isSnapshotMode = $derived(mode === 'snapshot'); - // Effective canEdit: for containers, use the prop; for volumes, only allow if not in use + // Effective canEdit: snapshots are always read-only; volumes only if not in use const effectiveCanEdit = $derived( - isVolumeMode ? (canEdit && !volumeIsInUse) : canEdit + isSnapshotMode ? false : isVolumeMode ? (canEdit && !volumeIsInUse) : canEdit ); // Effective container ID for file operations (use helper container for volume mode) @@ -145,6 +151,11 @@ // Editor/Viewer state let editingFile = $state<{ name: string; path: string; content: string } | null>(null); let viewingFile = $state<{ name: string; path: string; content: string } | null>(null); + // Preview-only loading flag (distinct from loadingFile, which also covers + // edit/download): drives the "Loading preview…" overlay so a slow restic dump + // from a remote repo (B2/S3) shows immediate feedback in-place. + let loadingPreview = $state(false); + let previewingName = $state(''); let editorContent = $state(''); let loadingFile = $state(false); // True when the editor buffer differs from the loaded file (unsaved changes). @@ -368,16 +379,22 @@ async function openFileForView(entry: FileEntry) { const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`; loadingFile = true; + loadingPreview = true; + previewingName = entry.name; try { - const params = new URLSearchParams({ path: filePath }); - if (envId) params.set('env', envId.toString()); - let res: Response; - if (isVolumeMode) { - res = await fetch(`/api/volumes/${encodeURIComponent(volumeName!)}/browse/content?${params}`); + if (isSnapshotMode) { + const params = new URLSearchParams({ destinationId: String(destinationId), path: filePath }); + res = await fetch(`/api/backup/snapshots/${snapshotId}/dump?${params}`); } else { - res = await fetch(`/api/containers/${effectiveContainerId}/files/content?${params}`); + const params = new URLSearchParams({ path: filePath }); + if (envId) params.set('env', envId.toString()); + if (isVolumeMode) { + res = await fetch(`/api/volumes/${encodeURIComponent(volumeName!)}/browse/content?${params}`); + } else { + res = await fetch(`/api/containers/${effectiveContainerId}/files/content?${params}`); + } } const data = await res.json(); @@ -394,6 +411,7 @@ toast.error(err.message || 'Failed to open file'); } finally { loadingFile = false; + loadingPreview = false; } } @@ -704,7 +722,11 @@ let res: Response; let data: any; - if (isVolumeMode) { + if (isSnapshotMode) { + const snapshotParams = new URLSearchParams({ destinationId: String(destinationId), path }); + res = await fetch(`/api/backup/snapshots/${snapshotId}/browse?${snapshotParams}`); + data = await res.json(); + } else if (isVolumeMode) { res = await fetch(`/api/volumes/${encodeURIComponent(volumeName!)}/browse?${params}`); data = await res.json(); @@ -738,7 +760,20 @@ } currentPath = data.path || path; - entries = data.entries || []; + // Map snapshot entries to match FileEntry interface + if (isSnapshotMode) { + entries = (data.entries || []).map((e: any) => ({ + name: e.name, + type: e.type === 'dir' ? 'directory' : e.type, + size: e.size || 0, + permissions: '', + owner: '', + group: '', + modified: e.mtime || '' + })); + } else { + entries = data.entries || []; + } } catch (err: any) { error = err.message; entries = []; @@ -778,17 +813,20 @@ function downloadFile(entry: FileEntry) { const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`; - const params = new URLSearchParams({ - path: filePath, - format: $appSettings.downloadFormat - }); - if (envId) params.set('env', envId.toString()); let url: string; - if (isVolumeMode) { - url = `/api/volumes/${encodeURIComponent(volumeName!)}/export?${params}`; + if (isSnapshotMode) { + const params = new URLSearchParams({ destinationId: String(destinationId), path: filePath, download: '1' }); + if (entry.type === 'directory') params.set('type', 'directory'); + url = `/api/backup/snapshots/${snapshotId}/dump?${params}`; } else { - url = `/api/containers/${effectiveContainerId}/files/download?${params}`; + const params = new URLSearchParams({ path: filePath, format: $appSettings.downloadFormat }); + if (envId) params.set('env', envId.toString()); + if (isVolumeMode) { + url = `/api/volumes/${encodeURIComponent(volumeName!)}/export?${params}`; + } else { + url = `/api/containers/${effectiveContainerId}/files/download?${params}`; + } } window.open(url, '_blank'); } @@ -841,8 +879,24 @@ return currentPath.split('/').filter(Boolean); }); + // Track all identity props so the effect re-fires when they change $effect(() => { - loadDirectory(initialPath); + // Read reactive values to establish dependencies + const _mode = mode; + const _snap = snapshotId; + const _dest = destinationId; + const _cont = containerId; + const _vol = volumeName; + const _path = initialPath; + + // Guard: don't load if required props for the mode are missing + if (_mode === 'snapshot' && (!_snap || !_dest)) return; + if (_mode === 'container' && !_cont) return; + + currentPath = _path; + entries = []; + error = null; + loadDirectory(_path); }); @@ -1195,7 +1249,12 @@ - {#if viewingFile} + {#if loadingPreview && !viewingFile} +
+ + Loading preview{previewingName ? ` — ${previewingName}` : ''}… +
+ {:else if viewingFile}
diff --git a/src/routes/containers/RestoreModal.svelte b/src/routes/containers/RestoreModal.svelte new file mode 100644 index 0000000..dad77ad --- /dev/null +++ b/src/routes/containers/RestoreModal.svelte @@ -0,0 +1,756 @@ + + + + + + + + + + Restore + {#if !loading}{#if targetIsStack}{:else}{/if}{/if} + {containerName} + {#if targetEnv} + {mode === 'in-place' ? 'on' : 'to'} + {targetEnvName} + {/if} + {snapshotId.slice(0, 8)} + {#if backupTime}{formatDateTime(backupTime)}{/if} + + Restore snapshot {snapshotId.slice(0, 8)} for {containerName}. + + + +
+ {#if loading} +
+ Reading snapshot… +
+ {:else if restoreStatus !== 'idle'} + + +
+ +
+ {#if restoreStatus === 'running'} + Restoring… + {:else if restoreStatus === 'success'} + + Restore completed + — {mode === 'new-location' + ? `restored to ${targetEnvName}${postRestore !== 'none' ? (targetIsStack ? ', stack redeployed' : ', container recreated') : ''}` + : `live volume replaced — restart ${containerName}${hasStackFiles ? ' / redeploy the stack' : ''} to use it`}. + {:else if restoreStatus === 'warning'} + Restore completed with warnings — {restoreWarning} + {:else} + {error || 'Restore failed'} + {/if} +
+
+ {:else} +
+ {#if error} +

{error}

+ {/if} + + +
+
+ 1 + +
+
+
Where does it go? — pick where, and how
+
+ + +
+ {#if mode === 'new-location'} +
+ + (targetEnvId = v ? parseInt(v) : undefined)}> + + {#if targetEnv} + {targetEnv.name} + {:else} + Select an environment… + {/if} + + + {#each envList as env} + + {env.name} + + {/each} + + +
+ {/if} +
+
+ + +
+
+ 2 + +
+
+
+ {mode === 'in-place' ? 'What gets overwritten?' : 'What gets restored?'} + — {selectedRows.length} of {volumes.length} {volumes.length === 1 ? 'volume' : 'volumes'} + {#if mode === 'new-location'} + + + +

Each destination is resolved by {targetEnvName || 'the target'}'s Docker daemon: a host path must exist on that host (not on Dockhand's), while a named volume is created there. Prefer named volumes for portability. The post-restore step brings the {targetIsStack ? 'stack' : 'container'} up; if it fails, the data is still restored and you finish manually.

+
+
+ {/if} +
+ {#if volumes.length === 0} +

This snapshot has no volumes — restore recreates the {targetIsStack ? 'stack' : 'container'} from its saved config.

+ {:else if mode === 'new-location'} + +
+ {#each volumes as vol} +
+ + {#if vol.selected} +
+ → + onDestKindChange(vol, v as 'volume' | 'path')}> + + {#if vol.destKind === 'path'} + Host path + {:else} + Volume + {/if} + + + Volume + Host path + + + +
+ {#if vol.conflict} +

Volume {vol.dest} already exists on {targetEnvName}. Remove it or choose another destination.

+ {:else if vol.pathInvalid} +

A host path must be absolute — start it with / (e.g. /srv/{vol.name}).

+ {/if} + {/if} +
+ {/each} +
+ {:else} +
+ {#each volumes as vol} + + {/each} +
+ {/if} +
+
+ + +
+
+ 3 +
+
+
Then what? — {mode === 'in-place' ? 'bring it back up' : 'bring it up on the target'}
+ {#if mode === 'new-location'} + +
+ + { postRestore = v as PostRestore; postRestoreUserPicked = true; }}> + + {#each postRestoreOptions as opt} + {#if opt.value === postRestore} + {@const OptIcon = opt.icon} + {opt.label} + {/if} + {/each} + + + {#each postRestoreOptions as opt} + {@const OptIcon = opt.icon} + + {opt.label} + + {/each} + + + {#if nameConflict} +

A {targetIsStack ? 'stack' : 'container'} named {containerName} already exists on {targetEnvName}. Remove it or choose "Do nothing" — the restore won't overwrite it.

+ {/if} +
+ {:else} +
+ + { postRestore = v as PostRestore; postRestoreUserPicked = true; }}> + + {#each postRestoreOptions as opt} + {#if opt.value === postRestore} + {@const OptIcon = opt.icon} + {opt.label} + {/if} + {/each} + + + {#each postRestoreOptions as opt} + {@const OptIcon = opt.icon} + + {opt.label} + + {/each} + + +
+ {/if} +
+
+ + + {#snippet envChip(env: { id: number; icon?: string | null } | undefined, name: string)} + {#if env}{/if}{name || '…'} + {/snippet} +
+
+ {#if mode === 'in-place'}{:else}{/if} + What will happen +
+ {#if mode === 'in-place'} +

+ The snapshot{#if backupTime} taken {formatDateTime(backupTime)}{/if}{#if sourceEnvName} from {@render envChip(sourceEnv, sourceEnvName)}{/if} will overwrite the live data{#if targetEnvName} on {@render envChip(targetEnv, targetEnvName)}{/if}{#if selectedRows.length > 0}:{:else}.{/if} +

+ {#if selectedRows.length > 0} +
    + {#each selectedRows as v} +
  • + {#if v.type === 'bind'}{:else}{/if} + {#if v.type === 'bind'}bind{:else}vol{/if} + {v.name} + wiped & replaced +
  • + {/each} +
+ {/if} +

The {targetIsStack ? 'stack' : 'container'} is stopped before the restore (expect downtime); the volumes are swapped (staged then committed), then {postRestoreLabel.toLowerCase()}.

+ + {:else} +

+ The snapshot{#if backupTime} taken {formatDateTime(backupTime)}{/if}{#if sourceEnvName} from {@render envChip(sourceEnv, sourceEnvName)}{/if} will be restored to {@render envChip(targetEnv, targetEnvName)}{#if selectedRows.length > 0}:{:else}.{/if} +

+ {#if selectedRows.length > 0} +
    + {#each selectedRows as v} +
  • + {#if v.type === 'bind'}{:else}{/if} + {#if v.type === 'bind'}bind{:else}vol{/if} + {v.name} + → {v.destKind === 'path' ? 'host path' : 'named volume'} + {v.dest.trim() || '…'} +
  • + {/each} +
+ {/if} + {#if postRestore !== 'none'} +

Then Dockhand will {postRestoreLabel.toLowerCase()} on {@render envChip(targetEnv, targetEnvName)}{#if sourceEnvName && sourceEnvName !== targetEnvName}. Nothing on {@render envChip(sourceEnv, sourceEnvName)} is touched{/if}.

+ {:else} +

The {targetIsStack ? 'stack' : 'container'} is not started — the data lands on {@render envChip(targetEnv, targetEnvName)} and you bring it up yourself.

+ {/if} + {#if hasRemap} + +
+
+ You have changed a volume's name or type +
+

Your data goes to the new volumes. But the {targetIsStack ? 'stack redeploys from the stored compose file, which still names' : 'recreated container mounts'} the original ones — so it {targetIsStack ? 'starts with empty volumes' : "won't see the restored data"}.

+

After restoring: {targetIsStack ? 'update the compose file to the new volume names, then redeploy.' : 'edit the container and point the mount at the new volume.'}

+ {#if postRestore === 'none'}

Next step set to Do nothing so it can't start with the wrong data.

{/if} +
+ {/if} + {/if} +
+
+ {/if} +
+ + + {#if restoreStatus === 'success' || restoreStatus === 'warning' || restoreStatus === 'error'} + + {:else if restoreStatus !== 'running'} + + + {#if !loading} + + {/if} + {/if} + +
+
diff --git a/src/routes/containers/SnapshotBrowser.svelte b/src/routes/containers/SnapshotBrowser.svelte new file mode 100644 index 0000000..cb78a43 --- /dev/null +++ b/src/routes/containers/SnapshotBrowser.svelte @@ -0,0 +1,422 @@ + + + + + + + + Browse snapshot + {#if snapshotEnvName} + from + + {snapshotEnvName} + {/if} + {#if displayName} + — + {#if (targetType || metadata?.type) === 'stack'} + + {:else} + + {/if} + {displayName} + {/if} + {#if destinationName} + on + + {destinationName} + {/if} + + + {#if snapshotId}{String(snapshotId).slice(0, 8)}{/if} + {#if snapshotTime}{formatDateTime(snapshotTime)}{/if} + + + + +
+ + +
+ + +
+ +
+ + + {#if activeTab === 'metadata'} +
+ {#if metadataLoading} +
+ {:else if metadataError} +

{metadataError}

+ {:else if metadata} +
+ +
+

Backup info

+
+
Type
+
{metadata.type}
+
Name
+
{metadata.targetName}
+
Backup time
+
{formatDateTime(metadata.backupTime)}
+ {#if metadata.environmentId != null} +
Environment ID
+
{metadata.environmentId}
+ {/if} +
Volumes
+
{metadata.volumes?.length ?? 0}
+ {#if metadata.type === 'stack'} + {#if metadata.composeFileName} +
Compose file
+
{metadata.composeFileName}
+ {/if} +
Stack files
+
{metadata.hasStackFiles ? (metadata.stackFilesTruncated ? 'captured (truncated)' : 'captured') : 'not captured'}
+ {/if} +
+
+ + + {#if metadata.volumes?.length} +
+

Volumes & binds ({metadata.volumes.length})

+
+ {#each metadata.volumes as vol} + +
+ + {#if vol.source && vol.source !== vol.name} + {vol.source} + → + {vol.name || vol.key} + {:else} + {vol.name || vol.key} + {/if} +
+ {/each} +
+
+ {/if} + + + {#if metadata.type === 'stack' && metadata.stackFilesList?.length} +
+

Stack files ({metadata.stackFilesList.length}{metadata.stackFilesListTruncated ? '+' : ''})

+
+ {#each metadata.stackFilesList as file} +
+ {file.path} + {#if file.path === metadata.composeFileName} + compose + {/if} + {formatBytes(file.bytes)} +
+ {/each} +
+ {#if metadata.stackFilesListTruncated} +

Only the first {metadata.stackFilesList.length} files are listed. Browse the snapshot to see the full tree.

+ {/if} +
+ {/if} + + + {#if containerInspect?.Config} +
+

+ + {(containerInspect.Name || metadata.targetName || '').replace(/^\//, '')} +

+
+
Image
+
{containerInspect.Config.Image || '—'}
+ {#if containerInspect.Id} +
Container ID
+
{containerInspect.Id.slice(0, 12)}
+ {/if} + {#if containerInspect.State?.Status} +
State at backup
+
{containerInspect.State.Status}
+ {/if} + {#if containerInspect.Created} +
Created
+
{formatDateTime(containerInspect.Created)}
+ {/if} + {#if containerInspect.Config.Cmd?.length} +
Command
+
{containerInspect.Config.Cmd.join(' ')}
+ {/if} + {#if containerInspect.Config.Entrypoint?.length} +
Entrypoint
+
{containerInspect.Config.Entrypoint.join(' ')}
+ {/if} + {#if containerInspect.Config.WorkingDir} +
Working dir
+
{containerInspect.Config.WorkingDir}
+ {/if} + {#if containerInspect.Config.User} +
User
+
{containerInspect.Config.User}
+ {/if} + {#if containerInspect.Config.ExposedPorts} +
Exposed ports
+
{Object.keys(containerInspect.Config.ExposedPorts).join(', ')}
+ {/if} + {#if containerInspect.HostConfig?.RestartPolicy?.Name} +
Restart policy
+
{containerInspect.HostConfig.RestartPolicy.Name}{containerInspect.HostConfig.RestartPolicy.MaximumRetryCount ? ` (max ${containerInspect.HostConfig.RestartPolicy.MaximumRetryCount})` : ''}
+ {/if} + {#if containerInspect.HostConfig?.NetworkMode} +
Network mode
+
{containerInspect.HostConfig.NetworkMode}
+ {/if} + {#if containerInspect.HostConfig?.Privileged} +
Privileged
+
yes
+ {/if} +
+ + + {#if containerInspect.HostConfig?.PortBindings && Object.keys(containerInspect.HostConfig.PortBindings).length} +
+ Port bindings +
+ {#each Object.entries(containerInspect.HostConfig.PortBindings) as [containerPort, hostArr]} + {@const h = (hostArr as any[])?.[0]} + {h?.HostIp ? `${h.HostIp}:` : ''}{h?.HostPort || '?'} → {containerPort} + {/each} +
+
+ {/if} + + + {#if containerInspect.State?.Health?.Status} +
+ Health at backup + {containerInspect.State.Health.Status} +
+ {/if} + + + {#if containerInspect.HostConfig?.Privileged || containerInspect.HostConfig?.ReadonlyRootfs || containerInspect.HostConfig?.CapAdd?.length || containerInspect.HostConfig?.CapDrop?.length} +
+ Security +
+ {#if containerInspect.HostConfig.Privileged}privileged{/if} + {#if containerInspect.HostConfig.ReadonlyRootfs}read-only rootfs{/if} + {#each containerInspect.HostConfig.CapAdd ?? [] as cap}+{cap}{/each} + {#each containerInspect.HostConfig.CapDrop ?? [] as cap}-{cap}{/each} +
+
+ {/if} + + + {#if containerInspect.HostConfig?.LogConfig?.Type || containerInspect.Config?.StopSignal || containerInspect.Platform} +
+ Runtime +
+ {#if containerInspect.HostConfig?.LogConfig?.Type} +
Log driver
+
{containerInspect.HostConfig.LogConfig.Type}
+ {/if} + {#if containerInspect.Config?.StopSignal} +
Stop signal
+
{containerInspect.Config.StopSignal}
+ {/if} + {#if containerInspect.Platform} +
Platform
+
{containerInspect.Platform}
+ {/if} +
+
+ {/if} + + + {#if containerInspect.HostConfig?.Dns?.length || containerInspect.HostConfig?.ExtraHosts?.length} +
+ DNS & hosts +
+ {#each containerInspect.HostConfig.Dns ?? [] as dns}DNS {dns}{/each} + {#each containerInspect.HostConfig.ExtraHosts ?? [] as host}{host}{/each} +
+
+ {/if} + + + {#if containerInspect.NetworkSettings?.Networks && Object.keys(containerInspect.NetworkSettings.Networks).length} +
+ Networks +
+ {#each Object.entries(containerInspect.NetworkSettings.Networks) as [net, cfg]} + {net}{(cfg as any)?.IPAddress ? ` (${(cfg as any).IPAddress})` : ''} + {/each} +
+
+ {/if} + + + {#if containerInspect.Config.Labels && Object.keys(containerInspect.Config.Labels).length} +
+ Labels ({Object.keys(containerInspect.Config.Labels).length}) +
+ {#each Object.entries(containerInspect.Config.Labels) as [key, value]} +
{key}={value}
+ {/each} +
+
+ {/if} + + + {#if containerInspect.Config.Env?.length} +
+ Environment variables ({containerInspect.Config.Env.length}) +
+ {#each containerInspect.Config.Env as envVar} + {@const eqIdx = envVar.indexOf('=')} +
+ {eqIdx > 0 ? envVar.slice(0, eqIdx) : envVar}{#if eqIdx > 0}=***{/if} +
+ {/each} +
+
+ {/if} +
+ {/if} +
+ {:else} +

No metadata available for this snapshot

+ {/if} +
+ {/if} +
+
diff --git a/src/routes/containers/SnapshotsPanel.svelte b/src/routes/containers/SnapshotsPanel.svelte new file mode 100644 index 0000000..12e3ae1 --- /dev/null +++ b/src/routes/containers/SnapshotsPanel.svelte @@ -0,0 +1,347 @@ + + + +{#if snapshots.length > 0} +
+ +
+{/if} + +{#if loading && snapshots.length === 0} + +
+ +

Loading snapshots…

+
+{:else if snapshots.length === 0} +
+ +

No snapshots yet for {targetName}.

+

Run a backup from the Schedules tab to create one.

+
+{:else} + + {#if repoGroups.length > 1} +
+ + {#each repoGroups as g} + {@const GIcon = getRepoTypeIcon(g.repository)} + + {/each} +
+ {/if} +
+ + + + + + + + + + + + {#each visibleSnapshots as s} + {@const isDiffPending = diffPending?.id === s.id} + {@const RepoIcon = getRepoTypeIcon(s._destinationRepository)} + {@const st = stats.get(s.id)} + + + + + + + + {/each} + +
SnapshotTakenAddedRepositoryActions
{s.shortId}{formatDateTime(s.time)}{st ? formatBytes(st.dataAdded) : '—'} + {s._destinationName} + +
+ {#if visibleSnapshots.length >= 2} + + {/if} + + + deleteSnapshot(s)} + onOpenChange={(open) => confirmDeleteSnapshot = open ? s.id : null} + > + {#snippet children({ open })} + {#if deletingSnapshot === s.id} + + {:else} + + {/if} + {/snippet} + +
+
+
+{/if} + + + + + + diff --git a/src/routes/dashboard/dashboard-cpu-memory-charts.svelte b/src/routes/dashboard/dashboard-cpu-memory-charts.svelte index d19c45f..d8d0025 100644 --- a/src/routes/dashboard/dashboard-cpu-memory-charts.svelte +++ b/src/routes/dashboard/dashboard-cpu-memory-charts.svelte @@ -38,17 +38,21 @@ return 'bg-emerald-500'; } + // Clamp to the chart's [0,100] yDomain so a transient >100% spike (e.g. a + // new container's first stats sample on a churny host) can't render above + // the frame and squash the real data (#1279). Mirrors the clamp on the + // displayed cpuPercent value above. const cpuChartData = $derived( metricsHistory?.map(m => ({ date: new Date(m.timestamp), - value: m.cpu_percent + value: Math.min(100, Math.max(0, m.cpu_percent)) })) ?? [] ); const memoryChartData = $derived( metricsHistory?.map(m => ({ date: new Date(m.timestamp), - value: m.memory_percent + value: Math.min(100, Math.max(0, m.memory_percent)) })) ?? [] ); diff --git a/src/routes/networks/NetworkInspectModal.svelte b/src/routes/networks/NetworkInspectModal.svelte index d2dfe3c..e8bc567 100644 --- a/src/routes/networks/NetworkInspectModal.svelte +++ b/src/routes/networks/NetworkInspectModal.svelte @@ -3,6 +3,7 @@ import { Button } from '$lib/components/ui/button'; import { Badge } from '$lib/components/ui/badge'; import { Loader2, Network } from 'lucide-svelte'; + import ModalHeader from '$lib/components/ModalHeader.svelte'; import { currentEnvironment, appendEnvParam } from '$lib/stores/environment'; import { formatDateTime } from '$lib/stores/settings'; import ContainerTile from '../containers/ContainerTile.svelte'; @@ -62,15 +63,12 @@ - + e.preventDefault()}> - - - Network details: {networkName || networkId.slice(0, 12)} - + -
+
{#if loading}
diff --git a/src/routes/schedules/+page.svelte b/src/routes/schedules/+page.svelte index cffb1d7..7820064 100644 --- a/src/routes/schedules/+page.svelte +++ b/src/routes/schedules/+page.svelte @@ -1,5 +1,7 @@ + +
+ +
+ + + +
+ +
+
+
+ + +
+ {destinations.length} destination{destinations.length !== 1 ? 's' : ''} +
+
+ {#if $canAccess('backups', 'manage')} + + {/if} + + +
+
+ + {#if loading && destinations.length === 0} +

Loading backup destinations...

+ {:else if destinations.length === 0} + + {:else} + openModal(dest)} + class="border-none" + wrapperClass="border rounded-lg" + > + {#snippet cell(column, dest)} + {#if column.id === 'type'} + {@const TypeIcon = getTypeIcon(dest.repository)} +
+ + {getTypeLabel(dest.repository)} +
+ {:else if column.id === 'name'} + {dest.name} + {:else if column.id === 'repository'} + + {dest.repository} + + {:else if column.id === 'usage'} + {@const usage = getUsageCount(dest.id)} + {#if usage.containers > 0 || usage.stacks > 0} +
+ {#if usage.containers > 0} + {usage.containers} + {/if} + {#if usage.stacks > 0} + {usage.stacks} + {/if} +
+ {:else} + — + {/if} + {:else if column.id === 'stats'} + {@const stat = repoStats.get(dest.id)} + {#if stat} +
+ {formatBytes(stat.totalSize)} + {stat.totalFiles} + {stat.snapshots} +
+ {:else if loadingStats.has(dest.id)} + + {:else} + — + {/if} + {:else if column.id === 'status'} + {#if dest.lastTestStatus === 'success'} +
+ + Initialized +
+ {:else if dest.lastTestStatus === 'needs_init'} +
+ + Needs init +
+ {:else if dest.lastTestStatus === 'failed'} + + +
+ + Failed +
+
+ {#if dest.lastTestError} + {dest.lastTestError} + {/if} +
+ {:else if testingAll} +
+ + Testing... +
+ {:else} +
+ + Not tested +
+ {/if} + {:else if column.id === 'actions'} + + +
e.stopPropagation()}> + {#if dest.lastTestStatus === 'success'} + + + + + + { confirmAction = null; runRepoTask(dest.id, 'unlock'); }} + onOpenChange={(o) => confirmAction = o ? { destId: dest.id, task: 'unlock' } : null} + > + {#snippet children({ open })} + {#if runningTask?.destId === dest.id && runningTask.task === 'unlock'}{:else}{/if} + {/snippet} + + { confirmAction = null; runRepoTask(dest.id, 'prune'); }} + onOpenChange={(o) => confirmAction = o ? { destId: dest.id, task: 'prune' } : null} + > + {#snippet children({ open })} + {#if runningTask?.destId === dest.id && runningTask.task === 'prune'}{:else}{/if} + {/snippet} + + { confirmAction = null; runRepoTask(dest.id, 'repair-index'); }} + onOpenChange={(o) => confirmAction = o ? { destId: dest.id, task: 'repair' } : null} + > + {#snippet children({ open })} + {#if runningTask?.destId === dest.id && runningTask.task === 'repair-index'}{:else}{/if} + {/snippet} + + {/if} + + {#if dest.lastTestStatus === 'needs_init'} + + {/if} + + + {#if $canAccess('backups', 'manage')} + + + deleteDestination(dest.id)} + onOpenChange={(open) => confirmDeleteId = open ? dest.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
+ {/if} + {/snippet} +
+ {/if} +
+ + { showModal = false; editingDest = null; }} + onSaved={fetchData} +/> + + + + + + + Repository snapshots on + + {browseDestName} + + +
+ {#if browseLoading} +
+ {:else if browseSnapshots.length === 0} +

No snapshots in this repository.

+ {:else} + {#snippet sortHead(field: BrowseSortField, label: string, extra = '')} + + + + {/snippet} + + + + {@render sortHead('shortId', 'ID', 'w-24')} + {@render sortHead('time', 'Created')} + {@render sortHead('type', 'Type', 'w-16')} + {@render sortHead('name', 'Name')} + Browse + + + + {#each sortedBrowseSnapshots as snap} + {@const name = snapName(snap)} + {@const type = snapType(snap)} + { snapshotBrowseDestId = browseDestId; snapshotBrowseId = snap.id; snapshotBrowseName = name; snapshotBrowseOpen = true; }}> + {snap.shortId} + {formatDateTime(snap.time)} + + {#if type === 'container'} + + {:else if type === 'stack'} + + {:else} + + {/if} + + {name} + + + + + + + {/each} + + + {/if} +
+ + + +
+
+ + + + + + diff --git a/src/routes/settings/backups/DestinationModal.svelte b/src/routes/settings/backups/DestinationModal.svelte new file mode 100644 index 0000000..c1a3216 --- /dev/null +++ b/src/routes/settings/backups/DestinationModal.svelte @@ -0,0 +1,679 @@ + + + { if (o) { formError = ''; repoConflictName = null; focusFirstInput(); } }}> + + + {isEditing ? 'Edit backup destination' : 'Add backup destination'} + {isEditing ? 'Update backup destination settings' : 'Configure a restic backup repository'} + + + {#if formError}

{formError}

{/if} + +
+ {#if selectedBackend.value === 'local'} +
+ + Local path repositories can only be used to back up and restore containers running on the same Docker host as Dockhand. For remote environments or cross-env restore, use S3, REST, or another remote backend. +
+ {/if} +
+ +
+
+ + +
+ +
+ + + + + + {selectedBackend.label} + + + + {#each backendTypes as backend} + + + + {backend.label} + + + {/each} + + +
+ + {#each repoFields as field} +
+ + { formFields[field.key] = (e.target as HTMLInputElement).value; }} + placeholder={field.placeholder} + /> +
+ {/each} +
+ + +
+ {#each credentialFields as field} + {#if field.key === 'skipHostKey'} +
+ + { formFields[field.key] = formFields[field.key] === 'true' ? '' : 'true'; }} /> +
+ {:else} +
+ + { formFields[field.key] = (e.target as HTMLInputElement).value; }} + placeholder={isEditing && field.secret ? '(leave blank to keep current)' : field.placeholder} + /> +
+ {/if} + {/each} + +
+ +
+ + + {#if formPassword} + + {/if} +
+

Restic encrypts all data with this password. You will need it to restore.

+
+ +
+ + +
+

Common flags:

+
+ { formFlags = (formFlags + ' --limit-upload 5120').trim(); }} title="Limit upload speed to 5 MB/s">--limit-upload + { formFlags = (formFlags + ' --limit-download 10240').trim(); }} title="Limit download speed to 10 MB/s">--limit-download + { formFlags = (formFlags + ' --verbose').trim(); }} title="Verbose output">--verbose + { formFlags = (formFlags + ' --compression max').trim(); }} title="Maximum compression">--compression max +
+
+
+
+
+ + +
+ Repository policies + + +
+
+
+ + + + + Removes unreferenced data from the repository. Frees disk space after snapshots are forgotten. Run monthly for most repos. + + +
+ +
+ {#if policyPruneEnabled} +
+ policyPruneSchedule = v} /> +
+ + +
+
+ {/if} +
+ + +
+
+
+ + + + + Verifies the structure and integrity of the repository. Detects corruption or missing data. Run monthly or after storage issues. + + +
+ +
+ {#if policyCheckEnabled} +
+ policyCheckSchedule = v} /> +
+ {/if} +
+ + +
+
+
+ + + + + Reads a random subset of data packs to verify they're readable and intact. Catches bit rot and storage corruption. Slower and uses bandwidth on cloud repos. Off by default. + + +
+ +
+ {#if policyVerifyEnabled} +
+ policyVerifySchedule = v} /> +
+ + policyVerifyDataSubset = v}> + {policyVerifyDataSubset} + + 5% + 10% + 25% + 50% + 100% + + +
+
+ {/if} +
+ + +
+
+ + + + + Automatically removes stale repository locks before prune and check operations. Locks can be left behind by interrupted or crashed backups. + + +
+ +
+
+ + + {#if isEditing} +
+
+ + Repository usage + {#if loadingStats} + + {/if} +
+ {#if repoStats} +
+
+
{formatBytes(repoStats.totalSize)}
+
Size
+
+
+
{repoStats.totalFiles.toLocaleString()}
+
Files
+
+
+
{repoStats.snapshots}
+
Snapshots
+
+
+ + {#if repoStats.totalSize > 0} +
+
+
+

{repoStats.snapshots} snapshots across {repoStats.totalFiles.toLocaleString()} files

+ {/if} + {:else if !loadingStats} +

Loading repository stats...

+ {/if} +
+ {/if} + +
+ {#if repoConflictName} +
+ + + This repository is already used by "{repoConflictName}". + Backups to a shared repository run one at a time (restic locks the repo), not in parallel. + Click Save anyway to continue. + +
+ {/if} + +
+ + {#if isEditing} + + {/if} +
+ + +
+
+
diff --git a/src/routes/settings/backups/RotatePasswordModal.svelte b/src/routes/settings/backups/RotatePasswordModal.svelte new file mode 100644 index 0000000..4299232 --- /dev/null +++ b/src/routes/settings/backups/RotatePasswordModal.svelte @@ -0,0 +1,144 @@ + + + + + + + + Rotate repository password + + + Change the restic encryption password for {destinationName}. The current + password is required for verification. + + + +
+
+ + +
+
+ + +
+
+ + + {#if confirmPassword.length > 0 && !passwordsMatch} +

Passwords do not match

+ {/if} + {#if newPassword.length > 0 && newPassword === currentPassword} +

New password must differ from current

+ {/if} +
+ + {#if errorMsg} +
+
+ +
+

+ {#if dbOutOfSync} + Repository rotated, database update failed + {:else} + Rotation failed + {/if} +

+

{errorMsg}

+ {#if dbOutOfSync} +

+ Open Edit destination and manually set the password to the new + value so Dockhand can talk to the repository again. +

+ {/if} +
+
+
+ {/if} +
+ + + + + +
+
diff --git a/src/routes/settings/backups/VerifyModal.svelte b/src/routes/settings/backups/VerifyModal.svelte new file mode 100644 index 0000000..6e213a3 --- /dev/null +++ b/src/routes/settings/backups/VerifyModal.svelte @@ -0,0 +1,130 @@ + + + + + + + Verify backup integrity + + {destinationName} + + + {#if status === 'idle'} +
+

+ Reads a random subset of data packs from the repository and verifies they are intact and readable. Larger subsets take longer but provide more thorough verification. +

+
+ Data to verify: + dataSubset = v}> + {dataSubset} + + 5% (quick) + 10% + 25% + 50% + 100% (full) + + +
+
+
+ + +
+ {:else} +
+ {#each logs as line} +
{line}
+ {/each} + {#if status === 'running'} +
+ Running... +
+ {/if} + {#if status === 'success'} +
+ Verification passed +
+ {/if} + {#if status === 'error'} +
+ {error} +
+ {/if} +
+
+ +
+ {/if} +
+
diff --git a/src/routes/settings/environments/EnvironmentBackupsTab.svelte b/src/routes/settings/environments/EnvironmentBackupsTab.svelte new file mode 100644 index 0000000..ad54065 --- /dev/null +++ b/src/routes/settings/environments/EnvironmentBackupsTab.svelte @@ -0,0 +1,275 @@ + + +{#if loading} +
+ +
+{:else if items.length === 0} +

No containers or stacks found on this environment.

+{:else} + + {#if unconfiguredCount > 0} + {#if !showBatch} +
+ +
+ {:else} +
+
+ batchDestId = v ? parseInt(v) : 0}> + + {#if batchDestId} + {@const dest = destList.find(d => d.id === batchDestId)} + {#if dest} + {@const DIcon = getRepoTypeIcon(dest.repository)} + {dest.name} + {/if} + {:else}Select repository{/if} + + + {#each usableDestinations as dest} + {@const DIcon = getRepoTypeIcon(dest.repository)} + + {dest.name} + + {/each} + + + batchSchedule = v} /> +
+
+ + +
+
+ {/if} + {/if} + +
+ {#each items as item} + {@const itemConfigs = getItemConfigs(item.name)} + {@const hasBackup = itemConfigs.length > 0} + {@const isExpanded = expandedItem === item.name} + +
+ + toggleExpand(item.name)} + > + {#if item.external} + + {:else if isExpanded} + + {:else} + + {/if} + {#if item.type === 'stack'} + + {:else} + + {/if} + + {item.name} + {#if item.git} + git + {/if} + {#if item.external} + external + {/if} + + + {#if item.external} + adopt first + {:else if hasBackup} + + {#if itemConfigs.length === 1} + {@const cfg = itemConfigs[0]} + {@const dest = destinations.get(cfg.destinationId)} + + {#if dest} + {@const DestIcon = getRepoTypeIcon(dest.repository)} + + {dest.name} + {/if} + {cfg.schedule ? formatCron(cfg.schedule) : 'manual'} + + {:else} + {itemConfigs.length} schedules + {/if} + {:else} + + configure + + {/if} + + + + {#if isExpanded && !item.external} +
+ +
+ {/if} +
+ {/each} +
+{/if} diff --git a/src/routes/settings/environments/EnvironmentModal.svelte b/src/routes/settings/environments/EnvironmentModal.svelte index 57e6f63..6ad3325 100644 --- a/src/routes/settings/environments/EnvironmentModal.svelte +++ b/src/routes/settings/environments/EnvironmentModal.svelte @@ -62,6 +62,7 @@ ChevronRight, XCircle, ImageUp, + Archive, Upload, ArrowRight } from 'lucide-svelte'; @@ -76,6 +77,8 @@ import TimezoneSelector from '$lib/components/TimezoneSelector.svelte'; import { whale } from '@lucide/lab'; import ImagePullProgressPopover from '../../images/ImagePullProgressPopover.svelte'; + import EnvironmentBackupsTab from './EnvironmentBackupsTab.svelte'; + import { page } from '$app/stores'; // BETA GATE: backups feature flag import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill'; import { ShieldOff } from 'lucide-svelte'; import { focusFirstInput } from '$lib/utils'; @@ -214,6 +217,16 @@ { id: 'vulnerability_any', label: 'Any vulns found', description: 'Any vulnerabilities found (medium/low)' } ] }, + { + id: 'backup', + label: 'Backup events', + events: [ + { id: 'backup_success', label: 'Backup succeeded', description: 'Backup completed successfully' }, + { id: 'backup_failed', label: 'Backup failed', description: 'Backup failed' }, + { id: 'restore_success', label: 'Restore succeeded', description: 'Restore completed successfully' }, + { id: 'restore_failed', label: 'Restore failed', description: 'Restore failed' } + ] + }, { id: 'system', label: 'System events', @@ -1545,7 +1558,7 @@ { if (o) focusFirstInput(); else onClose(); }}> - + {#if !isEditing} @@ -1564,7 +1577,7 @@ {/if} - + General @@ -1581,6 +1594,13 @@ Security + + {#if $page.data.backupsEnabled} + + + Backups + + {/if} Notifications @@ -2705,6 +2725,20 @@ + + + {#if environment?.id} + + {:else} +

Save the environment first to configure backups.

+ {/if} +
+
diff --git a/src/routes/settings/environments/EventTypesEditor.svelte b/src/routes/settings/environments/EventTypesEditor.svelte index 02e0a22..6221470 100644 --- a/src/routes/settings/environments/EventTypesEditor.svelte +++ b/src/routes/settings/environments/EventTypesEditor.svelte @@ -7,9 +7,11 @@ Layers, Shield, HardDrive, + Archive, ChevronDown, ChevronRight } from 'lucide-svelte'; + import { page } from '$app/stores'; // BETA GATE: backups feature flag interface EventType { id: string; @@ -103,6 +105,17 @@ { id: 'vulnerability_any', label: 'Any vulns found', description: 'Any vulnerabilities found (medium/low)' } ] }, + { + id: 'backup', + label: 'Backup events', + icon: Archive, + events: [ + { id: 'backup_success', label: 'Backup succeeded', description: 'Backup completed successfully' }, + { id: 'backup_failed', label: 'Backup failed', description: 'Backup failed' }, + { id: 'restore_success', label: 'Restore succeeded', description: 'Restore completed successfully' }, + { id: 'restore_failed', label: 'Restore failed', description: 'Restore failed' } + ] + }, { id: 'system', label: 'System events', @@ -151,7 +164,8 @@
- {#each NOTIFICATION_EVENT_GROUPS as group (group.id)} + + {#each NOTIFICATION_EVENT_GROUPS.filter((g) => g.id !== 'backup' || $page.data.backupsEnabled) as group (group.id)} {@const isCollapsed = collapsedGroups.has(group.id)} {@const selectedCount = getGroupSelectedCount(group)} {@const allSelected = selectedCount === group.events.length} diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte index 71c3781..44033a7 100644 --- a/src/routes/settings/general/GeneralTab.svelte +++ b/src/routes/settings/general/GeneralTab.svelte @@ -8,7 +8,7 @@ import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill'; import CronEditor from '$lib/components/cron-editor.svelte'; import TimezoneSelector from '$lib/components/TimezoneSelector.svelte'; - import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags, ChevronRight, ChevronDown } from 'lucide-svelte'; + import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags, Archive, ChevronRight, ChevronDown } from 'lucide-svelte'; import CodeEditor from '$lib/components/CodeEditor.svelte'; import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode, type LabelFilterMode } from '$lib/stores/settings'; import { canAccess, authStore } from '$lib/stores/auth'; @@ -91,6 +91,7 @@ services: let eventCollectionMode = $derived($appSettings.eventCollectionMode); let eventPollInterval = $derived($appSettings.eventPollInterval); let metricsCollectionInterval = $derived($appSettings.metricsCollectionInterval); + let defaultBackupImage = $derived($appSettings.defaultBackupImage); let clearingCache = $state(false); @@ -269,6 +270,14 @@ services: toast.success(`Metrics interval: ${selected.value / 1000}s`); } } + + function handleBackupImageBlur(e: Event) { + const value = (e.target as HTMLInputElement).value.trim(); + if (value && value !== defaultBackupImage) { + appSettings.setDefaultBackupImage(value); + toast.success('Backup image updated'); + } + }
@@ -970,15 +979,17 @@ services: onchange={handleScannerCleanupEnabledChange} disabled={!$canAccess('settings', 'edit')} /> -
+
+

Remove cached vulnerability databases to reclaim disk space

+ {#if scannerCleanupEnabled} +
-
-

Remove cached vulnerability databases to reclaim disk space

+ {/if}
diff --git a/src/routes/settings/notifications/NotificationModal.svelte b/src/routes/settings/notifications/NotificationModal.svelte index 6c93325..df53a10 100644 --- a/src/routes/settings/notifications/NotificationModal.svelte +++ b/src/routes/settings/notifications/NotificationModal.svelte @@ -6,15 +6,21 @@ import { Input } from '$lib/components/ui/input'; import { Badge } from '$lib/components/ui/badge'; import { TogglePill } from '$lib/components/ui/toggle-pill'; - import { Checkbox } from '$lib/components/ui/checkbox'; - import { Plus, Check, RefreshCw, Mail, Zap, Info, Send, CheckCircle2, XCircle, Key, ChevronDown, HelpCircle } from 'lucide-svelte'; + import { Plus, Check, RefreshCw, Mail, Zap, Send, CheckCircle2, XCircle, Bell, HelpCircle, Settings } from 'lucide-svelte'; + import * as Tabs from '$lib/components/ui/tabs'; import * as Tooltip from '$lib/components/ui/tooltip'; import { toast } from 'svelte-sonner'; import { focusFirstInput } from '$lib/utils'; // System-only events (configured at channel level, not per-environment) const SYSTEM_EVENTS = [ - { id: 'license_expiring', label: 'License expiring', description: 'Enterprise license expiring soon' } + { id: 'license_expiring', label: 'License expiring', description: 'Enterprise license expiring soon' }, + { id: 'repo_prune_success', label: 'Backup repository prune success', description: 'Scheduled repository prune completed successfully' }, + { id: 'repo_prune_failed', label: 'Backup repository prune failed', description: 'Scheduled repository prune failed' }, + { id: 'repo_check_success', label: 'Backup repository check success', description: 'Scheduled integrity check completed successfully' }, + { id: 'repo_check_failed', label: 'Backup repository check failed', description: 'Scheduled integrity check found errors or failed' }, + { id: 'repo_verify_success', label: 'Backup repository data verification success', description: 'Scheduled data verification completed successfully' }, + { id: 'repo_verify_failed', label: 'Backup repository data verification failed', description: 'Scheduled data verification found corruption or failed' } ] as const; export interface NotificationSetting { @@ -56,7 +62,7 @@ let formAppriseUrls = $state(''); // System events let formSystemEvents = $state([]); - let showSystemEvents = $state(false); + let activeTab = $state<'channel' | 'events'>('channel'); let formError = $state(''); let formSaving = $state(false); let formTesting = $state(false); @@ -78,7 +84,7 @@ formSmtpToEmails = ''; formAppriseUrls = ''; formSystemEvents = []; - showSystemEvents = false; + activeTab = 'channel'; formError = ''; formSaving = false; formTesting = false; @@ -114,7 +120,7 @@ // Load system events (filter to only system-scoped events) const systemEventIds = SYSTEM_EVENTS.map(e => e.id); formSystemEvents = (notification.eventTypes || []).filter(e => systemEventIds.includes(e as typeof SYSTEM_EVENTS[number]['id'])); - showSystemEvents = formSystemEvents.length > 0; + activeTab = 'channel'; formError = ''; formSaving = false; @@ -293,15 +299,32 @@ { if (o) { formError = ''; focusFirstInput(); } }}> - + {isEditing ? 'Edit' : 'Add'} notification channel -
- {#if formError} -
{formError}
- {/if} + {#if formError} +
{formError}
+ {/if} + + + + + + Channel + + + + System events + {#if formSystemEvents.length > 0} + {formSystemEvents.length} + {/if} + + + +
+
@@ -442,48 +465,31 @@ jsons://hostname/webhook/path"
{/if} - -
- - {#if showSystemEvents} -
-

- These events are not tied to specific environments and are configured globally here. -

+ + + +

+ These events are not tied to specific environments and are configured globally here. + Environment-specific events (containers, stacks, auto-updates) are configured in each environment's settings. +

+
{#each SYSTEM_EVENTS as event} - +
{/each}
- {/if} +
+ - -
-
- - Environment-specific events (containers, stacks, auto-updates) are configured in each environment's settings. -
-
-
+ {/if} - {currentPath || '/'} + {currentPath || '/'} {#if creatingFolder}
{#if scanning} diff --git a/src/routes/stacks/GitStackModal.svelte b/src/routes/stacks/GitStackModal.svelte index b0e1588..aca531d 100644 --- a/src/routes/stacks/GitStackModal.svelte +++ b/src/routes/stacks/GitStackModal.svelte @@ -7,8 +7,12 @@ import { Badge } from '$lib/components/ui/badge'; import { Input } from '$lib/components/ui/input'; import { TogglePill } from '$lib/components/ui/toggle-pill'; - import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert } from 'lucide-svelte'; + import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert, Settings2, Archive } from 'lucide-svelte'; import * as Tooltip from '$lib/components/ui/tooltip'; + import { page } from '$app/stores'; // BETA GATE: backups feature flag + import BackupPanel from '../containers/BackupPanel.svelte'; + import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts'; + import { fetchBackupExecutions } from '$lib/utils/backup'; import { copyToClipboard } from '$lib/utils/clipboard'; import CronEditor from '$lib/components/cron-editor.svelte'; import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte'; @@ -85,6 +89,49 @@ let formNewRepoBranch = $state('main'); let formNewRepoCredentialId = $state(null); + // Tabs: Settings (the deploy form) and Backups (edit mode + feature flag only). + let activeTab = $state<'settings' | 'backups'>('settings'); + // The stack's volumes, loaded lazily when the Backups tab opens (git stacks + // don't otherwise need them). Feeds the backup volume picker. + let stackVolumes = $state([]); + let stackVolumesLoaded = $state(false); + // Ok/fail run tally shown on the Backups tab (edit mode only). + let backupTally = $state<{ ok: number; failed: number }>({ ok: 0, failed: 0 }); + let backupTallyLoaded = $state(false); + const effectiveEnvId = $derived(gitStack?.environmentId ?? environmentId ?? null); + + async function loadBackupTally() { + if (backupTallyLoaded || !gitStack) return; + backupTallyLoaded = true; + try { + const p = new URLSearchParams({ target: formStackName, type: 'stack' }); + if (effectiveEnvId != null) p.set('env', String(effectiveEnvId)); + const res = await fetch(`/api/backup/configs?${p}`); + if (!res.ok) return; + const data = await res.json(); + const cfgs = Array.isArray(data) ? data : data?.id ? [data] : []; + if (cfgs.length > 0) { + const t = await fetchBackupExecutions(cfgs.map((c: any) => c.id)); + backupTally = { ok: t.ok, failed: t.failed }; + } + } catch { /* tally is best-effort */ } + } + + async function loadStackVolumes() { + if (stackVolumesLoaded) return; + stackVolumesLoaded = true; + try { + const url = effectiveEnvId != null ? `/api/containers?env=${effectiveEnvId}` : '/api/containers'; + const res = await fetch(url); + if (res.ok) stackVolumes = volumesForStack(await res.json(), formStackName); + } catch { /* picker just shows "no volumes" — backup still defaults to all */ } + } + + // Load volumes the first time the Backups tab is opened. + $effect(() => { + if (activeTab === 'backups') void loadStackVolumes(); + }); + // Form state - stack deployment config let formStackName = $state(''); let formStackNameUserModified = $state(false); @@ -104,8 +151,9 @@ let showExistsWarning = $state(false); let errors = $state<{ stackName?: string; repository?: string; repoName?: string; repoUrl?: string }>({}); - // Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores - const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; + // Stack name validation: Docker Compose requires lowercase; must start with a + // letter or number, and contain only lowercase letters, numbers, hyphens, underscores + const STACK_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/; let copiedWebhookUrl = $state<'ok' | 'error' | null>(null); let copiedWebhookSecret = $state<'ok' | 'error' | null>(null); @@ -350,6 +398,11 @@ async function resetForm() { // Clear state BEFORE async loads to avoid race conditions + activeTab = 'settings'; + stackVolumes = []; + stackVolumesLoaded = false; + backupTally = { ok: 0, failed: 0 }; + backupTallyLoaded = false; formError = ''; errors = {}; copiedWebhookUrl = null; @@ -363,6 +416,7 @@ formRepoMode = 'existing'; formRepositoryId = gitStack.repositoryId; formStackName = gitStack.stackName; + if ($page.data.backupsEnabled) void loadBackupTally(); formComposePath = gitStack.composePath; formEnvFilePath = gitStack.envFilePath; formAutoUpdate = gitStack.autoUpdate; @@ -416,7 +470,7 @@ errors.stackName = 'Stack name is required'; hasErrors = true; } else if (!STACK_NAME_REGEX.test(trimmedStackName)) { - errors.stackName = 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores'; + errors.stackName = 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores'; hasErrors = true; } @@ -600,6 +654,41 @@
+ + {#if gitStack && $page.data.backupsEnabled} +
+ + +
+ {/if} + + {#if activeTab === 'backups' && gitStack && $page.data.backupsEnabled} +
+ (backupTally = t)} + /> +
+ {:else}
@@ -1095,36 +1184,41 @@
+ {/if} - - {#if gitStack} - - - {:else} - + + + {#if activeTab !== 'backups'} + {#if gitStack} + + + {:else} + + {/if} {/if} diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte index 9131af1..eab2c2f 100644 --- a/src/routes/stacks/StackModal.svelte +++ b/src/routes/stacks/StackModal.svelte @@ -7,7 +7,10 @@ import CodeEditor, { type VariableMarker } from '$lib/components/CodeEditor.svelte'; import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte'; import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte'; - import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync } from 'lucide-svelte'; + import { Layers, Save, Play, Code, GitGraph, GitBranch, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, Archive } from 'lucide-svelte'; + import BackupPanel from '../containers/BackupPanel.svelte'; + import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts'; + import { fetchBackupExecutions } from '$lib/utils/backup'; import type { Component } from 'svelte'; import FilesystemBrowser from './FilesystemBrowser.svelte'; import PathBarItem from './PathBarItem.svelte'; @@ -16,6 +19,7 @@ import { Badge } from '$lib/components/ui/badge'; import { currentEnvironment, appendEnvParam } from '$lib/stores/environment'; import { appSettings } from '$lib/stores/settings'; + import { page } from '$app/stores'; // BETA GATE: backups feature flag import { focusFirstInput } from '$lib/utils'; import { copyToClipboard } from '$lib/utils/clipboard'; import * as Alert from '$lib/components/ui/alert'; @@ -57,9 +61,13 @@ let loadError = $state(null); let errors = $state<{ stackName?: string; compose?: string }>({}); let composeContent = $state(''); - let activeTab = $state<'editor' | 'graph'>('editor'); + let activeTab = $state<'editor' | 'graph' | 'backups'>('editor'); + let backupCount = $state(0); + let backupTally = $state<{ ok: number; failed: number }>({ ok: 0, failed: 0 }); let showConfirmClose = $state(false); let editorTheme = $state<'light' | 'dark'>('dark'); + // Ref to the embedded backup panel so close can check its inline form for unsaved edits. + let backupPanelRef = $state(undefined); // Environment variables state let envVars = $state([]); @@ -106,6 +114,8 @@ // Container info for untracked stacks let stackContainers = $state<{ name: string; state: string; image: string }[]>([]); + // Volumes/binds of this stack's containers, for the backup panel picker. + let stackVolumes = $state([]); // Derived: has user customized the compose path from auto-computed default? const isComposePathCustom = $derived( @@ -712,6 +722,16 @@ } } + // Populate the backup picker's volume/bind list from this stack's containers' + // mounts. Runs for BOTH the managed (internal) load path and the + // needs-file-location path, so an internal stack's backup panel is never empty. + async function loadStackVolumes(envId: number | null) { + const contRes = await fetch(appendEnvParam('/api/containers', envId)); + if (contRes.ok) { + stackVolumes = volumesForStack(await contRes.json(), stackName); + } + } + async function loadComposeFile() { if (mode !== 'edit' || !stackName) return; @@ -740,6 +760,22 @@ loadError = null; loading = false; // Important: stop loading spinner + // Fetch backup schedule count (BETA GATE: only when backups enabled) + if ($page.data.backupsEnabled) try { + const bp = new URLSearchParams({ target: stackName, type: 'stack' }); + if (envId) bp.set('env', String(envId)); + const bRes = await fetch(`/api/backup/configs?${bp}`); + if (bRes.ok) { + const bData = await bRes.json(); + const cfgs = Array.isArray(bData) ? bData : bData?.id ? [bData] : []; + backupCount = cfgs.length; + if (cfgs.length > 0) { + const t = await fetchBackupExecutions(cfgs.map((c: any) => c.id)); + backupTally = { ok: t.ok, failed: t.failed }; + } + } + } catch {} + // Fetch containers for this stack to show what's running try { const stacksRes = await fetch(appendEnvParam('/api/stacks', envId)); @@ -754,6 +790,10 @@ })); } } + + // Volumes/binds for the backup picker — derived from this stack's + // containers' mounts (same normalizer used by the other backup surfaces). + await loadStackVolumes(envId); } catch (e) { console.error('Failed to fetch stack containers:', e); } @@ -770,6 +810,13 @@ originalComposePath = data.composePath || null; originalEnvPath = data.envPath || null; + // Volumes/binds for the backup picker (managed/internal stack path). + try { + await loadStackVolumes(envId); + } catch (e) { + console.error('Failed to load stack volumes:', e); + } + // Load both env endpoints in parallel, then process results together const [envResponse, rawEnvResponse] = await Promise.all([ fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId)), @@ -1151,7 +1198,7 @@ } function tryClose() { - if (isDirty) { + if (isDirty || backupPanelRef?.isDirty()) { showConfirmClose = true; } else { handleClose(); @@ -1327,8 +1374,9 @@ if (isOpen) { focusFirstInput(); } else { - // Prevent closing if there are unsaved changes - show confirmation instead - if (isDirty) { + // Prevent closing if there are unsaved changes (stack edits OR a half-edited + // backup schedule in the embedded panel) - show confirmation instead + if (isDirty || backupPanelRef?.isDirty()) { // Re-open the dialog and show confirmation open = true; showConfirmClose = true; @@ -1358,7 +1406,7 @@ {stackName} {/if} {#if $currentEnvironment} - on {$currentEnvironment.name} + on {$currentEnvironment.name} {/if} @@ -1374,51 +1422,51 @@
-
- -
- - -
- - - {#if activeTab === 'editor'} - - {/if} - - - -
+ +
+ +
+ + + + {#if mode === 'edit' && $page.data.backupsEnabled && !needsFileLocation} + + {/if} +
+
{#if errors.compose} @@ -1487,7 +1535,12 @@
{#if activeTab === 'editor'} -
+
+ {#if readonly} + + Git · read-only + + {/if}
+ +
+ +
@@ -1636,6 +1703,17 @@ onContentChange={readonly ? undefined : handleGraphContentChange} {readonly} /> + {:else if activeTab === 'backups' && !needsFileLocation} + +
+ (backupTally = t)} + /> +
{/if}
{/if} diff --git a/src/routes/volumes/VolumeBrowserModal.svelte b/src/routes/volumes/VolumeBrowserModal.svelte index 66a13f3..fa5d863 100644 --- a/src/routes/volumes/VolumeBrowserModal.svelte +++ b/src/routes/volumes/VolumeBrowserModal.svelte @@ -3,6 +3,7 @@ import { HardDrive, Lock, Container } from 'lucide-svelte'; import { Badge } from '$lib/components/ui/badge'; import FileBrowserPanel from '../containers/FileBrowserPanel.svelte'; + import ModalHeader from '$lib/components/ModalHeader.svelte'; interface VolumeUsageInfo { containerId: string; @@ -50,18 +51,18 @@ - + e.preventDefault()}> - - - Browse volume - {volumeName} - {#if isInUse} - - - Read-only - - {/if} - + + {#snippet extra()} + {#if isInUse} + + + Read-only + + {/if} + {/snippet} + {#if isInUse} diff --git a/src/routes/volumes/VolumeInspectModal.svelte b/src/routes/volumes/VolumeInspectModal.svelte index e82ed2d..615d292 100644 --- a/src/routes/volumes/VolumeInspectModal.svelte +++ b/src/routes/volumes/VolumeInspectModal.svelte @@ -6,6 +6,7 @@ import { Loader2, HardDrive, Layers } from 'lucide-svelte'; import { goto } from '$app/navigation'; import { currentEnvironment, appendEnvParam } from '$lib/stores/environment'; + import ModalHeader from '$lib/components/ModalHeader.svelte'; import { formatDateTime } from '$lib/stores/settings'; interface Props { @@ -50,35 +51,35 @@ - + e.preventDefault()}> - - - Volume details: {volumeName} - {@const composeStack = volumeData?.Labels?.['com.docker.compose.project']} - {#if composeStack && !loading} - - - - - -

Open stack "{composeStack}"

-
-
- {/if} -
+ + {#snippet extra()} + {@const composeStack = volumeData?.Labels?.['com.docker.compose.project']} + {#if composeStack && !loading} + + + + + +

Open stack "{composeStack}"

+
+
+ {/if} + {/snippet} +