From 49809865dc59e651391e39e2b4aaf9d8cbaf76b2 Mon Sep 17 00:00:00 2001 From: jarek Date: Sun, 26 Jul 2026 10:27:12 +0200 Subject: [PATCH] 1.0.39 --- VERSION | 2 +- package.json | 3 +- src/lib/components/backup/VolumePicker.svelte | 4 + src/lib/data/changelog.json | 71 ++++++---- src/lib/server/backups/backup-script.ts | 10 +- src/lib/server/backups/helpers.ts | 7 + src/lib/server/backups/index.ts | 42 ++++-- src/lib/server/backups/restic.ts | 2 +- src/lib/server/backups/tar.ts | 97 ++++---------- src/lib/server/pending-updates-core.ts | 60 +++++++++ src/lib/server/stacks.ts | 72 +++++++--- src/lib/utils/mounts.ts | 76 ++++++----- src/routes/backups/+page.svelte | 24 +++- src/routes/backups/CreateBackupModal.svelte | 125 ++++++------------ .../backups/EditBackupConfigModal.svelte | 87 ++++++++++++ .../containers/EditContainerModal.svelte | 4 +- 16 files changed, 427 insertions(+), 259 deletions(-) create mode 100644 src/lib/server/pending-updates-core.ts create mode 100644 src/routes/backups/EditBackupConfigModal.svelte diff --git a/VERSION b/VERSION index c78fa8e..89741f3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.38 +v1.0.39 diff --git a/package.json b/package.json index 7bac6cd..cabdafb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.38", + "version": "1.0.39", "type": "module", "scripts": { "dev": "npx vite dev", @@ -82,6 +82,7 @@ "fast-xml-parser": "5.7.3", "js-yaml": "4.3.0", "ldapts": "8.1.3", + "modern-tar": "0.8.0", "nodemailer": "9.0.1", "otpauth": "9.4.1", "postgres": "3.4.8", diff --git a/src/lib/components/backup/VolumePicker.svelte b/src/lib/components/backup/VolumePicker.svelte index 17fe147..a3dd1e8 100644 --- a/src/lib/components/backup/VolumePicker.svelte +++ b/src/lib/components/backup/VolumePicker.svelte @@ -19,6 +19,7 @@ name: string; mountPoint: string; mountType?: 'volume' | 'bind'; + source?: string; } interface Props { @@ -81,6 +82,9 @@ /> {vol.name} + {#if vol.source && vol.source !== vol.name} + from {vol.source} + {/if} {#if vol.mountPoint} {vol.mountPoint} {/if} diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 60b4060..674ef5a 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,23 @@ [ + { + "version": "1.0.39", + "date": "2026-07-26", + "changes": [ + { + "type": "fix", + "text": "regression: stacks with relative bind mounts deployment fails (#1310)" + }, + { + "type": "fix", + "text": "redeploying a stack with pull now clears the dashboard pending-update badge for containers it updated (#1311)" + }, + { + "type": "fix", + "text": "stack backups no longer fail on deeply nested files whose path exceeds 100 bytes" + } + ], + "imageTag": "fnsys/dockhand:v1.0.39" + }, { "version": "1.0.38", "date": "2026-07-25", @@ -250,7 +269,7 @@ }, { "type": "fix", - "text": "Pangolin labels — recognise the real `public-resources` / `private-resources` namespaces (#2)" + "text": "Pangolin labels \u2014 recognise the real `public-resources` / `private-resources` namespaces (#2)" }, { "type": "fix", @@ -269,7 +288,7 @@ "changes": [ { "type": "feature", - "text": "raw file download — no tar wrapping (#1180)" + "text": "raw file download \u2014 no tar wrapping (#1180)" }, { "type": "fix", @@ -297,7 +316,7 @@ }, { "type": "feature", - "text": "Traefik and Pangolin integration — surface proxy URLs on container and stack panels (#2)" + "text": "Traefik and Pangolin integration \u2014 surface proxy URLs on container and stack panels (#2)" }, { "type": "feature", @@ -309,7 +328,7 @@ }, { "type": "feature", - "text": "template library — browse and deploy compose templates from configurable sources (#48)" + "text": "template library \u2014 browse and deploy compose templates from configurable sources (#48)" }, { "type": "fix", @@ -324,7 +343,7 @@ "changes": [ { "type": "feature", - "text": "in-place container property updates without restart — restart policy, CPU/memory limits (#1153)" + "text": "in-place container property updates without restart \u2014 restart policy, CPU/memory limits (#1153)" }, { "type": "feature", @@ -348,7 +367,7 @@ }, { "type": "feature", - "text": "Apprise passthrough — forward to a self-hosted caronc/apprise-api server (#1099)" + "text": "Apprise passthrough \u2014 forward to a self-hosted caronc/apprise-api server (#1099)" }, { "type": "fix", @@ -462,7 +481,7 @@ "changes": [ { "type": "fix", - "text": "502 Bad Gateway behind nginx-based reverse proxies — SvelteKit 2.51+ bloated the Link response header, pinned to 2.50.0 (#1114)" + "text": "502 Bad Gateway behind nginx-based reverse proxies \u2014 SvelteKit 2.51+ bloated the Link response header, pinned to 2.50.0 (#1114)" } ], "imageTag": "fnsys/dockhand:v1.0.31" @@ -473,11 +492,11 @@ "changes": [ { "type": "feature", - "text": "time range filter for log viewer — filter logs by From/To date and time (#1068)" + "text": "time range filter for log viewer \u2014 filter logs by From/To date and time (#1068)" }, { "type": "feature", - "text": "configurable tail line count in log viewer — choose from 100 to all lines (#1066)" + "text": "configurable tail line count in log viewer \u2014 choose from 100 to all lines (#1066)" }, { "type": "feature", @@ -485,7 +504,7 @@ }, { "type": "feature", - "text": "\"some unused\" image filter — show images with both used and unused tags for selective cleanup (#621)" + "text": "\"some unused\" image filter \u2014 show images with both used and unused tags for selective cleanup (#621)" }, { "type": "feature", @@ -497,7 +516,7 @@ }, { "type": "fix", - "text": "scan cache lookup by tag name never matched — results now resolved via image digest (#1064)" + "text": "scan cache lookup by tag name never matched \u2014 results now resolved via image digest (#1064)" }, { "type": "fix", @@ -509,7 +528,7 @@ }, { "type": "feature", - "text": "Gotify notification priority via URL query param — gotify://host/token?priority=5 (#1033)" + "text": "Gotify notification priority via URL query param \u2014 gotify://host/token?priority=5 (#1033)" }, { "type": "fix", @@ -517,7 +536,7 @@ }, { "type": "feature", - "text": "named custom URL labels — dockhand.url=[Name](https://...) markdown syntax (#1065)" + "text": "named custom URL labels \u2014 dockhand.url=[Name](https://...) markdown syntax (#1065)" }, { "type": "fix", @@ -533,7 +552,7 @@ }, { "type": "feature", - "text": "live network attach/detach for running containers — join or leave Docker networks without restarting (#1051)" + "text": "live network attach/detach for running containers \u2014 join or leave Docker networks without restarting (#1051)" }, { "type": "fix", @@ -556,7 +575,7 @@ }, { "type": "feature", - "text": "central label management — rename or delete labels across all environments (#661)" + "text": "central label management \u2014 rename or delete labels across all environments (#661)" }, { "type": "feature", @@ -564,7 +583,7 @@ }, { "type": "feature", - "text": "theme-aware scrollbar styling — scrollbars adapt to dark/light mode and color palettes (#462)" + "text": "theme-aware scrollbar styling \u2014 scrollbars adapt to dark/light mode and color palettes (#462)" }, { "type": "fix", @@ -612,7 +631,7 @@ }, { "type": "feature", - "text": "scan reports — combined or individual Grype/Trivy (#1056)" + "text": "scan reports \u2014 combined or individual Grype/Trivy (#1056)" } ], "imageTag": "fnsys/dockhand:v1.0.29" @@ -623,7 +642,7 @@ "changes": [ { "type": "feature", - "text": "context directory for git stacks — reference files from anywhere in the repo (#864)" + "text": "context directory for git stacks \u2014 reference files from anywhere in the repo (#864)" }, { "type": "feature", @@ -647,7 +666,7 @@ }, { "type": "fix", - "text": "env var values leaked in deploy logs — now all values are redacted (#985)" + "text": "env var values leaked in deploy logs \u2014 now all values are redacted (#985)" }, { "type": "fix", @@ -663,7 +682,7 @@ }, { "type": "fix", - "text": "case-sensitive environment sort order — lowercase names sorted after uppercase (#975)" + "text": "case-sensitive environment sort order \u2014 lowercase names sorted after uppercase (#975)" }, { "type": "fix", @@ -683,7 +702,7 @@ }, { "type": "feature", - "text": "public IP setting available for hawser-edge environments — enables clickable port links (#350)" + "text": "public IP setting available for hawser-edge environments \u2014 enables clickable port links (#350)" }, { "type": "fix", @@ -822,11 +841,11 @@ "changes": [ { "type": "feature", - "text": "API token authentication — Bearer tokens for CI/CD pipelines and scripts" + "text": "API token authentication \u2014 Bearer tokens for CI/CD pipelines and scripts" }, { "type": "feature", - "text": "Telegram topic support — send notifications to supergroup topics (#855)" + "text": "Telegram topic support \u2014 send notifications to supergroup topics (#855)" }, { "type": "fix", @@ -896,7 +915,7 @@ "changes": [ { "type": "feature", - "text": "theme toggle with system option — auto-follows OS light/dark preference (#803)" + "text": "theme toggle with system option \u2014 auto-follows OS light/dark preference (#803)" }, { "type": "feature", @@ -920,7 +939,7 @@ }, { "type": "fix", - "text": "stack restart fails for containers using network_mode: service: — added recreate option (#844)" + "text": "stack restart fails for containers using network_mode: service: \u2014 added recreate option (#844)" }, { "type": "fix", @@ -1096,7 +1115,7 @@ "changes": [ { "type": "feature", - "text": "Inline logs panel on stacks page — view container logs without leaving the page" + "text": "Inline logs panel on stacks page \u2014 view container logs without leaving the page" }, { "type": "feature", diff --git a/src/lib/server/backups/backup-script.ts b/src/lib/server/backups/backup-script.ts index 1880fd4..f8025d8 100644 --- a/src/lib/server/backups/backup-script.ts +++ b/src/lib/server/backups/backup-script.ts @@ -42,10 +42,14 @@ export interface MetadataFile { * tag/exclude/flag-assembled by the caller. */ export function buildBackupScript(resticArgs: string[]): string { - // Turn off `set -e` for the restic step so exit 3 (partial) doesn't abort - // before the marker is printed; the marker carries the real code. + // restic runs backgrounded so the SIGINT-forwarding trap can reach it on cancel + // (SIGINT lets restic release its repo lock; a SIGKILL orphans it and hangs the + // next backup on --retry-lock). The kill-0 re-wait loop only fires when a signal + // interrupted the first `wait` early; a normal exit skips it, keeping restic's + // real code (0/3) for the marker. const restic = `restic ${resticArgs.map(shellQuote).join(' ')}`; - return `mkdir -p /metadata; set +e; ${finishScript(restic)}`; + const step = `${restic} & __rpid=$!; trap 'kill -INT $__rpid 2>/dev/null' INT TERM; wait $__rpid; __rc=$?; while kill -0 $__rpid 2>/dev/null; do wait $__rpid; __rc=$?; done; ( exit $__rc )`; + return `mkdir -p /metadata; set +e; ${finishScript(step)}`; } /** diff --git a/src/lib/server/backups/helpers.ts b/src/lib/server/backups/helpers.ts index 9d9d29e..72a4fe4 100644 --- a/src/lib/server/backups/helpers.ts +++ b/src/lib/server/backups/helpers.ts @@ -251,6 +251,13 @@ export function cleanErrorMsg(msg: string): string { const message = parsed.message.replace(/\\n/g, ' ').trim(); return prefix ? `${prefix} ${message}` : message; } + if (parsed.message_type) { + // A killed helper appends restic's last --json PROGRESS line + // (message_type, no `message`) to the exit-code detail — no error + // text; drop it, keep just the readable prefix. + const p2 = clean.slice(0, jsonStart).trim().replace(/:\s*$/, ''); + if (p2) return p2; + } } catch { /* not embedded JSON */ } } return clean; diff --git a/src/lib/server/backups/index.ts b/src/lib/server/backups/index.ts index c597705..32105da 100644 --- a/src/lib/server/backups/index.ts +++ b/src/lib/server/backups/index.ts @@ -743,33 +743,55 @@ export async function runRepoTask(destinationId: number, task: RepoTask, opts?: // --- cancellation + running check --- -/** Kill every running helper container whose name starts with `prefix`, in the - * given env. Returns whether anything was killed. */ -async function killHelpers(prefix: string, envId: number | null | undefined): Promise { +/** Signal every running helper container whose name starts with `prefix`, in the + * given env (default SIGKILL). Returns the ids signalled. */ +async function killHelpers(prefix: string, envId: number | null | undefined, signal?: string): Promise { const { listContainers, dockerFetch } = await import('../docker'); - let killed = false; + const ids: string[] = []; + const q = signal ? `?signal=${encodeURIComponent(signal)}` : ''; try { const containers = await listContainers(true, envId ?? undefined); for (const c of containers) { if (c.state === 'running' && c.name?.startsWith(prefix)) { - try { await dockerFetch(`/containers/${c.id}/kill`, { method: 'POST' }, envId ?? undefined); killed = true; } catch { /* best effort */ } + try { await dockerFetch(`/containers/${c.id}/kill${q}`, { method: 'POST' }, envId ?? undefined); ids.push(c.id); } catch { /* best effort */ } } } } catch { /* daemon unreachable */ } - return killed; + return ids; } -/** Cancel a running backup: kill its helper so restic exits non-zero and the - * service's failure path runs. */ +/** Cancel a running backup GRACEFULLY: send SIGINT so restic releases its repo + * lock before exiting (a SIGKILL orphans the lock and the next backup hangs on + * --retry-lock). Safety net: if the helper is still alive ~15s later, SIGKILL it + * so a cancel never itself hangs. */ export async function cancelBackup(configId: number): Promise { const config = await getBackupConfig(configId); - return killHelpers(`dockhand-backup-${configId}`, config?.environmentId ?? undefined); + const envId = config?.environmentId ?? undefined; + const prefix = `dockhand-backup-${configId}`; + const signalled = await killHelpers(prefix, envId, 'SIGINT'); + if (signalled.length === 0) return false; + // Force-kill any helper that ignored SIGINT (pathological restic hang), so the + // user's cancel resolves regardless. Best-effort, out of band. + void (async () => { + const { listContainers, dockerFetch } = await import('../docker'); + for (let i = 0; i < 15; i++) { + await new Promise((r) => setTimeout(r, 1000)); + try { + const alive = (await listContainers(true, envId)).some((c) => c.state === 'running' && signalled.includes(c.id)); + if (!alive) return; + } catch { return; } + } + for (const id of signalled) { + try { await dockerFetch(`/containers/${id}/kill`, { method: 'POST' }, envId); } catch { /* gone */ } + } + })(); + return true; } /** Cancel a running restore. A blanket cancel (no snapshotId) never kills a * swap-recovery helper (that is a data-safety op, not a user restore). */ export async function cancelRestore(snapshotId?: string, environmentId?: number): Promise { - if (snapshotId) return killHelpers(`dockhand-restore-${snapshotId.slice(0, 12)}`, environmentId); + if (snapshotId) return (await killHelpers(`dockhand-restore-${snapshotId.slice(0, 12)}`, environmentId)).length > 0; // Blanket: kill restore helpers but NOT recovery helpers. const { listContainers, dockerFetch } = await import('../docker'); let killed = false; diff --git a/src/lib/server/backups/restic.ts b/src/lib/server/backups/restic.ts index 3dc312d..60da602 100644 --- a/src/lib/server/backups/restic.ts +++ b/src/lib/server/backups/restic.ts @@ -204,7 +204,7 @@ export class Restic { const metadataFiles = spec.metadataFiles ?? []; const beforeStart = metadataFiles.length > 0 ? async (containerId: string) => { - const tar = buildTar(metadataFiles.map((f) => ({ + const tar = await buildTar(metadataFiles.map((f) => ({ path: `metadata/${f.path.replace(/^\/+/, '')}`, content: Buffer.from(f.contentBase64, 'base64'), }))); diff --git a/src/lib/server/backups/tar.ts b/src/lib/server/backups/tar.ts index d7be96a..ad59154 100644 --- a/src/lib/server/backups/tar.ts +++ b/src/lib/server/backups/tar.ts @@ -1,80 +1,37 @@ /** - * backups/tar.ts — a tiny, pure USTAR builder for the backup helper. + * Build the tar we put-archive (docker cp) into the backup helper, replacing the + * base64-in-Cmd approach that blew ARG_MAX on large stack files. * - * The backup metadata files (metadata.json, and for stacks the compose/.env and - * the whole stackfiles/ tree) used to be written into the helper container by - * embedding each file's base64 as a shell argument in the container Cmd. That - * blows the kernel ARG_MAX for large stack files (a few MB of base64 on one arg - * line) and the helper dies with "argument list too long" before restic runs. - * - * Instead we ship the files into the container with the Docker put-archive API - * (docker cp), which streams a tar over HTTP with no arg-length limit. This - * module builds that tar. Pure (no I/O), so it is unit-testable directly. + * `modern-tar` is zero-dependency and emits PAX headers, so paths of ANY length + * round-trip (the old hand-rolled USTAR writer threw on names >100 bytes, breaking + * deep stack trees). Zero deps also matters for CI: it installs into a symlinked + * node_modules where libs with transitive deps fail to resolve them. */ +import { packTar } from 'modern-tar'; + export interface TarEntry { - /** Path inside the archive, e.g. "metadata/stacks/foo/compose.yaml". No leading slash. */ + /** Path inside the archive, e.g. "metadata/stacks/foo/compose.yaml". A leading slash is stripped. */ path: string; content: Uint8Array; } -const BLOCK = 512; - -function writeAscii(block: Uint8Array, offset: number, text: string): void { - const bytes = new TextEncoder().encode(text); - block.set(bytes, offset); -} - -/** Build a POSIX USTAR header block for one regular file. */ -function header(path: string, size: number, mtimeSecs: number): Uint8Array { - const h = new Uint8Array(BLOCK); - // Name (100). USTAR splits long names into prefix(345)+name(100); our paths - // are short (metadata/stacks//stackfiles/), so the 100-byte name - // field is enough. Truncation would corrupt the path, so guard callers. - writeAscii(h, 0, path.slice(0, 100)); - writeAscii(h, 100, '0000644\0'); // mode 0644 - writeAscii(h, 108, '0000000\0'); // uid 0 - writeAscii(h, 116, '0000000\0'); // gid 0 - writeAscii(h, 124, size.toString(8).padStart(11, '0') + '\0'); // size (octal) - writeAscii(h, 136, mtimeSecs.toString(8).padStart(11, '0') + '\0'); // mtime (octal) - writeAscii(h, 148, ' '); // checksum placeholder (spaces) while summing - h[156] = 48; // type flag '0' = regular file - writeAscii(h, 257, 'ustar\0'); // magic - writeAscii(h, 263, '00'); // version - writeAscii(h, 265, 'root'); - writeAscii(h, 297, 'root'); - - let checksum = 0; - for (let i = 0; i < BLOCK; i++) checksum += h[i]; - writeAscii(h, 148, checksum.toString(8).padStart(6, '0') + '\0 '); - return h; -} - -/** - * Build a USTAR archive from the given entries. Paths longer than 100 bytes are - * rejected (the callers use short, bounded paths) rather than silently truncated. - * `mtimeSecs` is injectable so the builder stays pure/deterministic for tests. - */ -export function buildTar(entries: TarEntry[], mtimeSecs = Math.floor(Date.now() / 1000)): Uint8Array { - const blocks: Uint8Array[] = []; - for (const e of entries) { - const path = e.path.replace(/^\/+/, ''); - if (new TextEncoder().encode(path).length > 100) { - throw new Error(`tar entry path too long for USTAR name field (>100 bytes): ${path}`); - } - blocks.push(header(path, e.content.length, mtimeSecs)); - const padded = Math.ceil(e.content.length / BLOCK) * BLOCK; - const body = new Uint8Array(padded); - body.set(e.content, 0); - blocks.push(body); - } - // Two zero blocks terminate the archive. - blocks.push(new Uint8Array(BLOCK)); - blocks.push(new Uint8Array(BLOCK)); - - const total = blocks.reduce((n, b) => n + b.length, 0); - const out = new Uint8Array(total); - let off = 0; - for (const b of blocks) { out.set(b, off); off += b.length; } - return out; +/** Build a tar (any path length via PAX). `mtimeSecs` injectable for deterministic tests. */ +export function buildTar(entries: TarEntry[], mtimeSecs = Math.floor(Date.now() / 1000)): Promise { + return packTar( + entries.map((e) => ({ + header: { + name: e.path.replace(/^\/+/, ''), + size: e.content.length, + mode: 0o644, + mtime: new Date(mtimeSecs * 1000), + uid: 0, + gid: 0, + uname: 'root', + gname: 'root', + type: 'file' as const, + }, + body: e.content, + })), + ); } diff --git a/src/lib/server/pending-updates-core.ts b/src/lib/server/pending-updates-core.ts new file mode 100644 index 0000000..c7385ea --- /dev/null +++ b/src/lib/server/pending-updates-core.ts @@ -0,0 +1,60 @@ +/** + * Pure logic to reconcile `pending_container_updates` after a stack redeploy (#1311). + * No docker/db imports, so it's unit-testable with fakes. + * + * A redeploy recreates containers with new ids, orphaning the pending rows (keyed by + * old id). We clear a row ONLY when the container provably runs the newest local + * image for its tag — a pull can fail, so every ambiguity KEEPs the row (never hide + * a real update). + */ + +export interface PendingRow { + containerId: string; + containerName: string; + currentImage: string; // image NAME/tag (e.g. "nginx:latest"), not a sha256 +} + +export interface LiveContainer { + name: string; + imageId: string; // sha256 the container RUNS (container.ImageID) + project?: string; // com.docker.compose.project label +} + +/** + * The `containerId`s to remove after a pulled redeploy of `stackName` (container + * names are unique per host, so a row maps to at most one live container). The + * per-row decision is inline below. `latestIdForTag` wraps getImageIdByTag. + */ +export function pendingRowsToClear( + pending: PendingRow[], + liveContainers: LiveContainer[], + latestIdForTag: (tag: string) => string | null, + stackName: string +): string[] { + if (!Array.isArray(pending) || pending.length === 0) return []; + + const liveByName = new Map(); + for (const c of liveContainers ?? []) { + if (c && c.name) liveByName.set(c.name, c); + } + + const toClear: string[] = []; + for (const row of pending) { + if (!row || !row.containerId) continue; + const live = liveByName.get(row.containerName); + + if (!live) { + toClear.push(row.containerId); // orphan: no live container by this name + continue; + } + if (live.project !== stackName) continue; // another stack / standalone — not ours + + const latest = latestIdForTag(row.currentImage); + if (latest && live.imageId && live.imageId === latest) { + toClear.push(row.containerId); // running the newest local image → updated + } + // else: still old (pull failed / build: / never) or tag unresolvable → keep + } + + return toClear; +} diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index da67476..7c92b7b 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -42,8 +42,9 @@ import { unregisterSchedule } from './scheduler'; import { sendEventNotification } from './notifications'; import { deleteGitStackFiles, parseEnvFileContent } from './git'; import { cleanPem } from '$lib/utils/pem'; -import { rewriteComposeVolumePaths, getHostDataDir, findRelativeBindSources } from './host-path'; +import { rewriteComposeVolumePaths, getHostDataDir } from './host-path'; import { getOrderValue } from './container-labels'; +import { pendingRowsToClear } from './pending-updates-core'; // ============================================================================= // TYPES @@ -1042,26 +1043,6 @@ async function executeLocalCompose( } } - // Cross-env safety check: if we're targeting a remote daemon (DOCKER_HOST - // set) and the compose has relative bind sources (./X, ../X), refuse the - // 'up' deploy with an actionable error. Otherwise docker compose expands - // ./X against Dockhand's CWD and asks the REMOTE daemon to bind that path, - // which silently creates an empty directory on the remote — the service - // starts with no data and the deploy reports success. (Other operations - // like down/stop/restart don't bind anything, so they pass through.) - if (dockerHost && operation === 'up') { - const relativeBinds = findRelativeBindSources(finalComposeContent); - if (relativeBinds.length > 0) { - const message = `This stack uses relative bind mounts (${relativeBinds.join(', ')}) which cannot be deployed to a remote Docker environment. The source paths are resolved on Dockhand's filesystem, not on the target host's. To fix: convert the bind mounts to named volumes, or use absolute paths that exist on the target host.`; - console.log(`${logPrefix} REFUSED: ${message}`); - return { - success: false, - output: '', - error: message - }; - } - } - // Build spawn environment with ONLY essential system variables. // CRITICAL: Do NOT spread process.env! Docker Compose shell env has higher // priority than --env-file, so Dockhand's vars would override user's .env values. @@ -2561,6 +2542,45 @@ async function notifyStackDeploy(name: string, envId: number | null | undefined, } catch { /* never changes the deploy outcome */ } } +/** + * After a pulled stack redeploy, clear the dashboard "pending update" rows for this + * stack's containers now on the newest local image (#1311). Fire-and-forget: fully + * wrapped so nothing here can affect the already-succeeded deploy; only DELETEs rows. + */ +async function reconcileStackPendingUpdates(stackName: string, envId: number): Promise { + try { + const pending = await getPendingContainerUpdates(envId); + if (!pending || pending.length === 0) return; + + const { listContainers, getImageIdByTag } = await import('./docker.js'); + const containers = await listContainers(true, envId); + const live = containers.map((c) => ({ + name: c.name, + imageId: c.imageId, + project: c.labels?.['com.docker.compose.project'] + })); + + // Resolve each distinct pending tag to its newest local image id once. + const tagCache = new Map(); + for (const p of pending) { + if (!tagCache.has(p.currentImage)) { + try { + tagCache.set(p.currentImage, await getImageIdByTag(p.currentImage, envId)); + } catch { + tagCache.set(p.currentImage, null); // unresolvable → keep (fail-safe) + } + } + } + + const toClear = pendingRowsToClear(pending, live, (tag) => tagCache.get(tag) ?? null, stackName); + for (const id of toClear) { + await removePendingContainerUpdate(envId, id).catch(() => {}); + } + } catch { + // Never let update-badge cleanup affect a deploy that already succeeded. + } +} + /** * Deploy a stack (create or update) * Uses stack locking to prevent concurrent deployments. @@ -2763,6 +2783,16 @@ export async function deployStack(options: DeployStackOptions): Promise((resolve) => setTimeout(resolve, 15000)) + ]).catch(() => {}); + } return result; }); } diff --git a/src/lib/utils/mounts.ts b/src/lib/utils/mounts.ts index d48d712..c3ddd01 100644 --- a/src/lib/utils/mounts.ts +++ b/src/lib/utils/mounts.ts @@ -1,20 +1,16 @@ /** - * Canonical volume/bind normalization for the backup UI. - * - * Three surfaces (CreateBackupModal, BackupPanel via EditContainerModal, and - * EnvironmentBackupsTab) previously each open-coded "Docker mounts -> pickable - * volume list" and drifted: one dropped bind mounts entirely, one never set a - * mount type (so every row rendered as a named volume). Both symptoms trace to - * duplicated logic. This module is the single source of truth. - * - * Kept dependency-free (no Svelte/lucide/db imports) so it is unit-testable - * under `bun test`. + * Canonical "Docker mounts -> pickable volume list" for the backup UI — the single + * source of truth, so the surfaces that use it can't drift. Dependency-free so it's + * unit-testable under `bun test`. */ export interface VolumeInfo { name: string; mountPoint: string; mountType: 'volume' | 'bind'; + /** Bind mounts only: the host source path, for display (name is the container + * destination so it matches the backup engine's filter). */ + source?: string; } /** A raw Docker mount as returned by the containers API (list or inspect). @@ -38,10 +34,26 @@ export function mountTypeFromHostPath(hostPath: string): 'volume' | 'bind' { return hostPath.startsWith('/') ? 'bind' : 'volume'; } +/** + * A parsed bind entry (`hostPath:containerPath`) -> pickable VolumeInfo. `name` MUST + * match what the backup engine discovers (named volume -> its name/hostPath, bind -> + * its container destination), else the selectedVolumes filter drops it silently. + */ +export function volumeInfoFromBind(bind: { hostPath: string; containerPath: string }): VolumeInfo { + const mountType = mountTypeFromHostPath(bind.hostPath); + return { + name: mountType === 'bind' ? bind.containerPath : bind.hostPath, + mountPoint: bind.containerPath, + mountType + }; +} + /** * Normalize a container's raw Docker mounts into the pickable volume list. - * Keeps only `volume` and `bind` mounts (skips tmpfs/npipe/etc.), and derives - * a stable display `name` (volume name, else source, else destination). + * Keeps only `volume` and `bind` mounts (skips tmpfs/npipe/etc.). `name` MUST + * equal what the backup engine discovers (named volume -> its name, bind -> its + * container destination), else the selectedVolumes filter drops it silently; the + * host source is kept separately in `source` for display only. */ export function normalizeMounts(mounts: RawMount[] | null | undefined): VolumeInfo[] { if (!Array.isArray(mounts)) return []; @@ -49,12 +61,14 @@ export function normalizeMounts(mounts: RawMount[] | null | undefined): VolumeIn for (const m of mounts) { const type = m.type || m.Type; if (type !== 'volume' && type !== 'bind') continue; - const name = m.name || m.Name || m.source || m.Source || m.destination || m.Destination || ''; - out.push({ - name, - mountPoint: m.destination || m.Destination || '', - mountType: type === 'bind' ? 'bind' : 'volume' - }); + const destination = m.destination || m.Destination || ''; + const source = m.source || m.Source || ''; + if (type === 'bind') { + out.push({ name: destination || source, mountPoint: destination, mountType: 'bind', source: source || undefined }); + } else { + const name = m.name || m.Name || source || destination || ''; + out.push({ name, mountPoint: destination, mountType: 'volume' }); + } } return out; } @@ -112,15 +126,11 @@ function composeProject(c: RawContainer): string | undefined { } /** - * Partition a raw /api/containers list into stack items (grouped by the compose - * project label, mounts merged+deduped across the stack's containers) and - * standalone container items. Sorted: stacks first, then containers, each - * alphabetical. - * - * NOTE: deriving stacks from container labels can't see a stack's sourceType and - * misses stopped stacks (no live containers). Backup-discovery surfaces now take - * stacks from /api/stacks and standalone containers from {@link standaloneContainers}. - * This whole-list grouping is kept for any caller that only has a container list. + * Partition a /api/containers list into stack items (grouped by compose-project label, + * mounts merged+deduped) and standalone containers; stacks first, then containers, + * each alphabetical. Deriving stacks from labels misses stopped stacks — most + * backup surfaces prefer /api/stacks + {@link standaloneContainers}; this is for + * callers that only have a container list. */ export function groupContainersForBackup(containers: RawContainer[] | null | undefined): BackupItem[] { if (!Array.isArray(containers)) return []; @@ -164,15 +174,9 @@ export function volumesForStack( } /** - * The STANDALONE-container slice of a /api/containers list: every container - * WITHOUT a compose-project label, as a backup item with its mounts normalized. - * Sorted alphabetically. - * - * This exists because the stack slice of a backup list must now come from - * /api/stacks (the single source that knows a stack's sourceType and includes - * stopped stacks) — reconstructing stacks from container labels missed both. So - * the caller takes stacks from /api/stacks and standalone containers from here, - * instead of {@link groupContainersForBackup} (which does both from labels). + * Standalone containers (no compose-project label) of a /api/containers list, as + * backup items, sorted alphabetically. Pairs with stacks from /api/stacks (which, + * unlike label-grouping, sees stopped stacks and their sourceType). */ export function standaloneContainers( containers: RawContainer[] | null | undefined diff --git a/src/routes/backups/+page.svelte b/src/routes/backups/+page.svelte index 7faa8fe..da6941f 100644 --- a/src/routes/backups/+page.svelte +++ b/src/routes/backups/+page.svelte @@ -19,7 +19,7 @@ import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte'; import { Archive, Box, Layers, ChevronDown, ChevronRight, RefreshCw, Search, Play, Pause, Trash2, FolderOpen, RotateCcw, - CheckCircle, XCircle, AlertCircle, Loader2, Clock, X, ArrowLeftRight, Package + CheckCircle, XCircle, AlertCircle, Loader2, Clock, X, ArrowLeftRight, Package, Pencil } from 'lucide-svelte'; import RotateCwFadingClock from '$lib/components/icons/RotateCwFadingClock.svelte'; import { formatDateTime } from '$lib/stores/settings'; @@ -31,6 +31,7 @@ import BackupLogModal from './BackupLogModal.svelte'; import SnapshotDiffModal from './SnapshotDiffModal.svelte'; import CreateBackupModal from './CreateBackupModal.svelte'; + import EditBackupConfigModal from './EditBackupConfigModal.svelte'; interface BackupConfig { key: string; @@ -137,6 +138,9 @@ let confirmDeleteConfig = $state(null); let deletingConfig = $state(null); let togglingConfig = $state(null); + // Edit-config modal (reuses the container/stack Backups tab — BackupPanel). + let editModalOpen = $state(false); + let editConfig = $state(null); // Snapshot browser / restore let showBrowser = $state(false); @@ -506,6 +510,14 @@ } } + // The Create-backup wizard hands off a freshly-saved config here to run through the + // shared BackupLogModal (same as Run-now), instead of an embedded log. + async function runBackupNowById({ configId }: { configId: number; targetName: string }) { + await fetchData(); + const config = configs.find((c) => c.id === configId); + if (config) runBackupNow(config); + } + // Delete a backup CONFIG (its schedule too — the server unregisters it). The // snapshots already in the repo are NOT touched; the target then shows as an // Pause/resume a scheduled backup. A minimal PUT with only `enabled` is a @@ -843,6 +855,9 @@ {#if togglingConfig === config.id}{:else if config.enabled}{:else}{/if} {/if} + @@ -1035,4 +1050,11 @@ + + { editModalOpen = false; fetchData(); }} /> diff --git a/src/routes/backups/CreateBackupModal.svelte b/src/routes/backups/CreateBackupModal.svelte index ee73687..18162c1 100644 --- a/src/routes/backups/CreateBackupModal.svelte +++ b/src/routes/backups/CreateBackupModal.svelte @@ -8,28 +8,29 @@ import { Checkbox } from '$lib/components/ui/checkbox'; import { TogglePill } from '$lib/components/ui/toggle-pill'; import CronEditor from '$lib/components/cron-editor.svelte'; - import { Package, Box, Layers, Search, Loader2, CheckCircle2, XCircle, ArrowBigRight, Settings, Clock, Play } from 'lucide-svelte'; + import { Package, Box, Layers, Search, Loader2, CheckCircle2, ArrowBigRight, Settings, Clock, Play } from 'lucide-svelte'; import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte'; - import { getRepoTypeIcon, formatCron, runBackupAction, isRemoteEnvironment, tagLogLine, type BackupFormState } from '$lib/utils/backup'; + import { getRepoTypeIcon, formatCron, runBackupAction, isRemoteEnvironment, type BackupFormState } from '$lib/utils/backup'; import { toast } from 'svelte-sonner'; import VolumePicker from '$lib/components/backup/VolumePicker.svelte'; import DestinationPicker from '$lib/components/backup/DestinationPicker.svelte'; - import LogConsole from '$lib/components/LogConsole.svelte'; import { normalizeMounts, volumesForStack, type VolumeInfo } from '$lib/utils/mounts'; interface Props { open: boolean; onCreated?: () => void; + /** Called after the config is saved with intent to RUN — the parent opens the + * shared BackupLogModal for it (same log UI as Run-now). */ + onRun?: (r: { configId: number; targetName: string }) => void; } - let { open = $bindable(), onCreated }: Props = $props(); + let { open = $bindable(), onCreated, onRun }: Props = $props(); interface Environment { id: number; name: string; icon?: string; connectionType?: string; host?: string | null; } interface Destination { id: number; name: string; repository: string; } interface ContainerItem { name: string; type: 'container' | 'stack'; envId: number; envName: string; envIcon?: string; volumes: VolumeInfo[]; } - // Step state (4 = running/complete) - let step = $state<1 | 2 | 3 | 4>(1); + let step = $state<1 | 2 | 3>(1); // Step 1: Select source let environments = $state([]); @@ -50,12 +51,7 @@ let saveSchedule = $state(false); let schedule = $state('0 2 * * *'); let scheduleInvalid = $state(false); - let running = $state(false); - - // Step 4: Running log - let logs = $state([]); - let backupStatus = $state<'idle' | 'running' | 'success' | 'error'>('idle'); - let backupError = $state(''); + let saving = $state(false); $effect(() => { if (open) { @@ -69,10 +65,7 @@ selectedVolumes = []; saveSchedule = false; schedule = '0 2 * * *'; - running = false; - logs = []; - backupStatus = 'idle'; - backupError = ''; + saving = false; fetchEnvironments(); fetchDestinations(); } @@ -135,16 +128,12 @@ step = 2; } - async function runBackup() { + // Save the config, then (when run=true) hand it off to the parent's shared + // BackupLogModal — same log UI as Run-now everywhere else. run=false saves the + // recurring schedule without running now (it fires on cron). + async function saveAndMaybeRun(run: boolean) { if (!selectedItem || !selectedDestId) return; - running = true; - logs = []; - backupStatus = 'running'; - backupError = ''; - - // Wizard exposes two intents: "run and save the schedule" or - // "run once and discard". Both map cleanly onto the shared action - // orchestrator — same code path as BackupPanel's Save & run / Run once. + saving = true; const form: BackupFormState = { targetName: selectedItem.name, type: selectedItem.type, @@ -154,34 +143,28 @@ allVolumes, selectedVolumes }; - try { const result = await runBackupAction({ form, - action: saveSchedule ? 'save-run' : 'run-once', - schedule, - enabled: true, - onProgress: (line) => { - if (line.event === 'progress') { - const msg = (line.data as { message?: string } | null)?.message; - if (msg) logs = [...logs, tagLogLine(msg)]; - } - } + action: 'save', // persist only; the run is delegated to the parent modal + schedule: saveSchedule ? schedule : '', + enabled: saveSchedule }); - - if (!result.ok) { - backupStatus = 'error'; - backupError = result.error || 'Backup failed'; - } else { - backupStatus = 'success'; - toast.success(saveSchedule ? 'Backup completed and schedule saved' : 'Backup completed'); + if (!result.ok || !result.configId) { + toast.error(result.error || 'Failed to save backup'); + return; } onCreated?.(); + if (run) { + onRun?.({ configId: result.configId, targetName: selectedItem.name }); + } else { + toast.success('Schedule saved'); + } + open = false; } catch (err: any) { - backupStatus = 'error'; - backupError = err?.message || 'Backup failed'; + toast.error(err?.message || 'Failed to save backup'); } finally { - running = false; + saving = false; } } @@ -200,7 +183,7 @@ const isSelectedEnvRemote = $derived(isRemoteEnvironment(selectedEnv)); - { if (!isOpen && running) return; open = isOpen; }}> + { if (!isOpen && saving) return; open = isOpen; }}> @@ -382,52 +365,20 @@

{/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/EditBackupConfigModal.svelte b/src/routes/backups/EditBackupConfigModal.svelte new file mode 100644 index 0000000..96cbff6 --- /dev/null +++ b/src/routes/backups/EditBackupConfigModal.svelte @@ -0,0 +1,87 @@ + + + + + + + + Edit backup — {config?.targetName ?? ''} + + + +
+ {#if config} + {#if loading} +
+ Loading volumes… +
+ {:else} + onSaved?.()} + /> + {/if} + {/if} +
+
+
diff --git a/src/routes/containers/EditContainerModal.svelte b/src/routes/containers/EditContainerModal.svelte index b6acfc0..9f29001 100644 --- a/src/routes/containers/EditContainerModal.svelte +++ b/src/routes/containers/EditContainerModal.svelte @@ -7,7 +7,7 @@ import { focusFirstInput } from '$lib/utils'; import ContainerSettingsTab from './ContainerSettingsTab.svelte'; import BackupPanel from './BackupPanel.svelte'; - import { mountTypeFromHostPath } from '$lib/utils/mounts'; + import { volumeInfoFromBind } from '$lib/utils/mounts'; import { fetchBackupExecutions } from '$lib/utils/backup'; import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte'; import { parseHostPort, expandPortBindings, formatHostPort } from '$lib/utils/port-parse'; @@ -1198,7 +1198,7 @@ v.hostPath && v.containerPath).map(v => ({ name: v.containerPath, mountPoint: v.hostPath, mountType: mountTypeFromHostPath(v.hostPath) }))} + volumes={volumeMappings.filter(v => v.hostPath && v.containerPath).map(volumeInfoFromBind)} type="container" onTally={(t) => (backupTally = t)} />