1.0.39
This commit is contained in:
16 files changed
+427
-259
No files matched your search
+2
-1
@@ -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",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
name: string;
|
||||
mountPoint: string;
|
||||
mountType?: 'volume' | 'bind';
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -81,6 +82,9 @@
|
||||
/>
|
||||
<MountTypeBadge type={vol.mountType} size="sm" />
|
||||
<span class="font-mono truncate">{vol.name}</span>
|
||||
{#if vol.source && vol.source !== vol.name}
|
||||
<span class="text-muted-foreground font-mono truncate">from {vol.source}</span>
|
||||
{/if}
|
||||
{#if vol.mountPoint}
|
||||
<span class="text-muted-foreground ml-auto truncate">{vol.mountPoint}</span>
|
||||
{/if}
|
||||
|
||||
+45
-26
@@ -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:<container> — added recreate option (#844)"
|
||||
"text": "stack restart fails for containers using network_mode: service:<container> \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",
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<boolean> {
|
||||
/** 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<string[]> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
|
||||
@@ -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'),
|
||||
})));
|
||||
|
||||
@@ -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/<name>/stackfiles/<rel>), 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<Uint8Array> {
|
||||
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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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<string, LiveContainer>();
|
||||
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;
|
||||
}
|
||||
+51
-21
@@ -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<void> {
|
||||
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<string, string | null>();
|
||||
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<StackOpe
|
||||
// path funnels through, so all of them notify (#1295). A git deploy suppresses the
|
||||
// stack_* notification (deployGitStack sends git_sync_*).
|
||||
await notifyStackDeploy(name, envId, result, isGitDeploy ?? false);
|
||||
|
||||
// Clear stale pending-update badges (#1311). Fire-and-forget with a timeout so a
|
||||
// slow Docker API can't delay or affect the already-succeeded deploy.
|
||||
if (result.success && pullPolicy && typeof envId === 'number') {
|
||||
const envIdNum = envId;
|
||||
void Promise.race([
|
||||
reconcileStackPendingUpdates(name, envIdNum),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 15000))
|
||||
]).catch(() => {});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
+40
-36
@@ -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
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
let deletingConfig = $state<number | null>(null);
|
||||
let togglingConfig = $state<number | null>(null);
|
||||
// Edit-config modal (reuses the container/stack Backups tab — BackupPanel).
|
||||
let editModalOpen = $state(false);
|
||||
let editConfig = $state<BackupConfig | null>(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}<RefreshCw class="w-3 h-3 text-muted-foreground animate-spin" />{:else if config.enabled}<Pause class="w-3 h-3 text-muted-foreground" />{:else}<RotateCwFadingClock class="w-3 h-3 text-muted-foreground" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => { editConfig = config; editModalOpen = true; }} title="Edit backup">
|
||||
<Pencil class="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => runBackupNow(config)} disabled={runningBackup === config.id} title="Run backup now">
|
||||
{#if runningBackup === config.id}<RefreshCw class="w-3 h-3 text-muted-foreground animate-spin" />{:else}<Play class="w-3 h-3 text-muted-foreground" />{/if}
|
||||
</button>
|
||||
@@ -1035,4 +1050,11 @@
|
||||
<CreateBackupModal
|
||||
bind:open={showCreateModal}
|
||||
onCreated={fetchData}
|
||||
onRun={runBackupNowById}
|
||||
/>
|
||||
|
||||
<EditBackupConfigModal
|
||||
bind:open={editModalOpen}
|
||||
config={editConfig}
|
||||
onSaved={() => { editModalOpen = false; fetchData(); }}
|
||||
/>
|
||||
@@ -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<Environment[]>([]);
|
||||
@@ -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<string[]>([]);
|
||||
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));
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen && running) return; open = isOpen; }}>
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen && saving) return; open = isOpen; }}>
|
||||
<Dialog.Content class="max-w-3xl h-[70vh] flex flex-col">
|
||||
<Dialog.Header class="pb-0">
|
||||
<Dialog.Title class="flex items-center gap-2 text-base">
|
||||
@@ -382,52 +365,20 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if backupStatus === 'idle'}
|
||||
<!-- Not started yet -->
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
{#if backupStatus === 'idle' || (backupStatus !== 'running' && logs.length === 0)}
|
||||
<Button variant="outline" size="sm" onclick={() => step = 2} disabled={running}>Back</Button>
|
||||
{/if}
|
||||
<Button variant="outline" size="sm" onclick={() => step = 2} disabled={saving}>Back</Button>
|
||||
<div class="flex-1"></div>
|
||||
{#if backupStatus === 'success'}
|
||||
<Button size="sm" onclick={() => { open = false; }}>
|
||||
<CheckCircle2 class="w-3.5 h-3.5 mr-1.5 text-green-500" />Done
|
||||
</Button>
|
||||
{:else if backupStatus === 'error' && !running}
|
||||
<Button size="sm" variant="outline" onclick={() => { backupStatus = 'idle'; logs = []; }}>
|
||||
Retry
|
||||
</Button>
|
||||
<Button size="sm" onclick={() => { open = false; }}>Close</Button>
|
||||
{:else}
|
||||
<Button size="sm" onclick={runBackup} disabled={running || (saveSchedule && scheduleInvalid)}>
|
||||
{#if running}<Loader2 class="w-3.5 h-3.5 mr-1.5 animate-spin" />{:else}<Play class="w-3.5 h-3.5 mr-1.5" />{/if}
|
||||
{saveSchedule ? 'Run & save schedule' : 'Run backup now'}
|
||||
{#if saveSchedule}
|
||||
<Button variant="outline" size="sm" onclick={() => saveAndMaybeRun(false)} disabled={saving || scheduleInvalid}>
|
||||
{#if saving}<Loader2 class="w-3.5 h-3.5 mr-1.5 animate-spin" />{:else}<CheckCircle2 class="w-3.5 h-3.5 mr-1.5" />{/if}
|
||||
Save schedule only
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" onclick={() => saveAndMaybeRun(true)} disabled={saving || (saveSchedule && scheduleInvalid)}>
|
||||
{#if saving}<Loader2 class="w-3.5 h-3.5 mr-1.5 animate-spin" />{:else}<Play class="w-3.5 h-3.5 mr-1.5" />{/if}
|
||||
{saveSchedule ? 'Run & save schedule' : 'Run backup now'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Backup log — same shared renderer (pills, auto-scroll) as the backup
|
||||
panel and restore modal, with the wizard's own status line below. -->
|
||||
{#if logs.length > 0 || running}
|
||||
<div class="mt-3 flex min-h-0 flex-1 flex-col">
|
||||
<LogConsole lines={logs} class="flex-1 min-h-0" />
|
||||
{#if backupStatus === 'running'}
|
||||
<div class="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3 w-3 animate-spin" />Backing up…
|
||||
</div>
|
||||
{:else if backupStatus === 'success'}
|
||||
<div class="mt-1.5 flex items-center gap-1.5 text-xs font-medium text-green-500">
|
||||
<CheckCircle2 class="h-3.5 w-3.5" />Backup completed
|
||||
</div>
|
||||
{:else if backupStatus === 'error'}
|
||||
<div class="mt-1.5 flex items-start gap-1.5 text-xs font-medium text-red-400">
|
||||
<XCircle class="mt-0.5 h-3.5 w-3.5 shrink-0" /><span class="break-all">{backupError || 'Failed'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Edit a backup config from the Backups page by reusing the container/stack
|
||||
* Backups tab (BackupPanel) in a modal. BackupPanel needs the target's volume
|
||||
* list, which this page fetches like CreateBackupModal does.
|
||||
*/
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Loader2, Archive } from 'lucide-svelte';
|
||||
import BackupPanel from '../containers/BackupPanel.svelte';
|
||||
import { normalizeMounts, volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
|
||||
|
||||
interface EditTarget {
|
||||
id: number;
|
||||
type: 'container' | 'stack';
|
||||
targetName: string;
|
||||
environmentId: number | null;
|
||||
}
|
||||
|
||||
let { open = $bindable(), config, onSaved }: {
|
||||
open: boolean;
|
||||
config: EditTarget | null;
|
||||
onSaved?: () => void;
|
||||
} = $props();
|
||||
|
||||
let volumes = $state<VolumeInfo[]>([]);
|
||||
let loading = $state(false);
|
||||
let loadedFor = $state<number | null>(null); // config.id we loaded volumes for
|
||||
|
||||
async function loadVolumes(cfg: EditTarget) {
|
||||
loading = true;
|
||||
volumes = [];
|
||||
try {
|
||||
const envId = cfg.environmentId;
|
||||
const contRes = await fetch(`/api/containers?env=${envId}`);
|
||||
const contData = await contRes.json();
|
||||
if (cfg.type === 'stack') {
|
||||
volumes = Array.isArray(contData) ? volumesForStack(contData, cfg.targetName) : [];
|
||||
} else {
|
||||
const c = Array.isArray(contData)
|
||||
? contData.find((x: any) => (x.name || x.Names?.[0]?.replace(/^\//, '')) === cfg.targetName)
|
||||
: null;
|
||||
volumes = c ? normalizeMounts(c.mounts || c.Mounts) : [];
|
||||
}
|
||||
} catch {
|
||||
volumes = [];
|
||||
}
|
||||
loadedFor = cfg.id;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
// Fetch volumes once per opened config.
|
||||
$effect(() => {
|
||||
if (open && config && loadedFor !== config.id) {
|
||||
loadVolumes(config);
|
||||
}
|
||||
if (!open) loadedFor = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-3xl h-[70vh] flex flex-col">
|
||||
<Dialog.Header class="pb-0">
|
||||
<Dialog.Title class="flex items-center gap-2 text-base">
|
||||
<Archive class="w-4 h-4" />
|
||||
Edit backup — {config?.targetName ?? ''}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
{#if config}
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 p-4 text-sm text-muted-foreground">
|
||||
<Loader2 class="w-4 h-4 animate-spin" /> Loading volumes…
|
||||
</div>
|
||||
{:else}
|
||||
<BackupPanel
|
||||
containerName={config.targetName}
|
||||
type={config.type}
|
||||
environmentId={config.environmentId ?? undefined}
|
||||
{volumes}
|
||||
onConfigSaved={() => onSaved?.()}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -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 @@
|
||||
<BackupPanel
|
||||
bind:this={backupPanelRef}
|
||||
containerName={name}
|
||||
volumes={volumeMappings.filter(v => 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)}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user