1058 lines
57 KiB
Svelte
1058 lines
57 KiB
Svelte
<script lang="ts">
|
|
import * as Dialog from '$lib/components/ui/dialog';
|
|
import * as Select from '$lib/components/ui/select';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Input } from '$lib/components/ui/input';
|
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
|
import { Label } from '$lib/components/ui/label';
|
|
import { Badge } from '$lib/components/ui/badge';
|
|
import { RotateCcw, AlertTriangle, Loader2, HardDrive, Folder, Clock, Play, CheckCircle2, XCircle, Server, PackagePlus, Ban, Rocket, Box, Layers, HelpCircle, Info, KeyRound, FileX } from 'lucide-svelte';
|
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
|
import { toast } from 'svelte-sonner';
|
|
import { watchJob } from '$lib/utils/sse-fetch';
|
|
import { environments } from '$lib/stores/environment';
|
|
import { formatDateTime } from '$lib/stores/settings';
|
|
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
|
import SnapshotHeader from '$lib/components/backup/SnapshotHeader.svelte';
|
|
import LogConsole from '$lib/components/LogConsole.svelte';
|
|
import { tagLogLine, classifyJobResult, getRepoTypeIcon } from '$lib/utils/backup';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
destinationId: number;
|
|
snapshotId: string;
|
|
containerName: string;
|
|
environmentId?: number;
|
|
/** Source repository (name + repo URL) this snapshot is being restored FROM.
|
|
* Shown in the header as "from <icon> <name>" so it's clear which destination
|
|
* the restore reads. Optional: the header just omits it when not supplied. */
|
|
destinationName?: string;
|
|
destinationRepository?: string;
|
|
/** Refresh callback fired once the restore succeeds — the caller reloads its
|
|
* snapshot list. This modal is ALWAYS self-contained (form → log → result in
|
|
* one dialog); it never delegates its progress UI to a parent. */
|
|
onDone?: () => void;
|
|
}
|
|
let { open = $bindable(), destinationId, snapshotId, containerName, environmentId, destinationName, destinationRepository, onDone }: Props = $props();
|
|
|
|
// The two restore modes, framed by DESTINATION intent (not safe-vs-destructive):
|
|
// - 'new-location' ("Restore to environment"): pick a target env and, per
|
|
// volume, a DESTINATION. A destination pointing at a named volume / host path
|
|
// the container mounts + a start action = a working CLONE; a plain extract
|
|
// path = today's safe inspect-to-a-folder. Cross-env.
|
|
// - 'in-place' ("Overwrite live"): overwrite the live volumes on the SOURCE env.
|
|
type RestoreMode = 'new-location' | 'in-place';
|
|
|
|
let loading = $state(false);
|
|
let restoring = $state(false);
|
|
let error = $state('');
|
|
let restoreStatus = $state<'idle' | 'running' | 'success' | 'warning' | 'error'>('idle');
|
|
let restoreLogs = $state<string[]>([]);
|
|
// Non-fatal caveat shown on the 'warning' result (e.g. data restored but the
|
|
// post-restore recreate/redeploy failed — the operator finishes manually).
|
|
let restoreWarning = $state('');
|
|
|
|
// After the restore, what to do with the target.
|
|
type PostRestore = 'start' | 'recreate' | 'redeploy' | 'none';
|
|
|
|
// One row per volume. `dest`/`destKind` are the editable clone destination on
|
|
// the target env (pre-filled 1:1 from the snapshot's original location). `type`
|
|
// is the ORIGINAL kind (for the badge). `conflict` is set when a named-volume
|
|
// destination already exists on the target env (fail-closed — must be resolved).
|
|
interface VolRow {
|
|
name: string;
|
|
selected: boolean;
|
|
type: 'volume' | 'bind';
|
|
dest: string;
|
|
destKind: 'volume' | 'path';
|
|
/** The snapshot's ORIGINAL destination + kind (the 1:1 pre-fill), kept so we
|
|
* can detect when the user redirects a volume to a new name/type — which the
|
|
* recreate/redeploy won't follow (it uses the stored config). */
|
|
origDest: string;
|
|
origDestKind: 'volume' | 'path';
|
|
conflict: boolean;
|
|
/** A host-path destination that isn't a valid absolute path (empty, no
|
|
* leading '/', or contains '..') — mirrors the server-side validation so the
|
|
* restore is blocked before submit with an inline hint. */
|
|
pathInvalid: boolean;
|
|
}
|
|
|
|
let mode = $state<RestoreMode>('new-location');
|
|
let volumes = $state<VolRow[]>([]);
|
|
let hasMetadata = $state(false);
|
|
let hasStackFiles = $state(false);
|
|
let backupTime = $state<string | null>(null);
|
|
let targetPath = $state('/restore');
|
|
let confirmOverwrite = $state(false);
|
|
let postRestore = $state<PostRestore>('recreate');
|
|
// Set once the user picks a post-restore action by hand, so the auto "→ none on
|
|
// remap" doesn't override a deliberate choice (they may know their config matches).
|
|
let postRestoreUserPicked = $state(false);
|
|
let targetEnvId = $state<number | undefined>(undefined);
|
|
// The snapshot's SOURCE env for in-place. Seeded from the environmentId prop, but
|
|
// the prop can be empty (a config with no env, or an orphan snapshot restored from
|
|
// the list) — in that case we recover it from the snapshot metadata on preview, so
|
|
// in-place still knows which env holds the live target (otherwise the button stays
|
|
// disabled and the header shows "on " with no name).
|
|
let sourceEnvId = $state<number | undefined>(undefined);
|
|
// Names (never values) of the secrets carried IN this snapshot. The values are
|
|
// stored encrypted under this instance's key; restoring writes them back into the
|
|
// target env's DB so the stack comes up complete. Default ON — a restore is meant to
|
|
// reproduce a working stack. Off = bring it up without secrets (re-enter by hand).
|
|
let sourceSecretKeys = $state<string[]>([]);
|
|
let restoreSecrets = $state(true);
|
|
// New-location STACK restore option. skipStackFiles: restore only the volume DATA, not the
|
|
// captured compose/.env (the operator already has the compose). When NOT set, the stack files
|
|
// are restored AND materialised into Dockhand's managed stack dir so it can edit/redeploy.
|
|
let skipStackFiles = $state(false);
|
|
// Volume names that already exist on the chosen target env (for conflict marks).
|
|
let existingTargetVolumes = $state<string[]>([]);
|
|
// Target-NAME collision: a container/stack of the restore's name already exists
|
|
// on the chosen target env. Mirrors the server's fail-closed check so the user
|
|
// sees it before submitting. Only blocks when a bring-up action is selected.
|
|
let nameExistsOnTarget = $state(false);
|
|
|
|
const envList = $derived($environments ?? []);
|
|
const effectiveEnvId = $derived(mode === 'in-place' ? sourceEnvId : targetEnvId);
|
|
const targetIsStack = $derived(hasStackFiles);
|
|
|
|
// After-restore action options. For a clone, 'start' is not offered (there's no
|
|
// live container to start — the target is created); 'recreate'/'redeploy' build it.
|
|
const postRestoreOptions = $derived([
|
|
...(mode === 'in-place' ? [{ value: 'start', label: targetIsStack ? 'Start stack' : 'Start container', icon: Play }] : []),
|
|
{ value: 'recreate', label: targetIsStack ? 'Recreate if missing' : 'Recreate container', icon: PackagePlus },
|
|
...(targetIsStack ? [{ value: 'redeploy', label: 'Redeploy stack', icon: Rocket }] : []),
|
|
{ value: 'none', label: 'Do nothing', icon: Ban }
|
|
]);
|
|
const targetEnv = $derived(envList.find((e) => e.id === effectiveEnvId));
|
|
const targetEnvName = $derived(targetEnv?.name ?? '');
|
|
// The snapshot's source env (for the "from <env>" phrasing in the summary).
|
|
const sourceEnv = $derived(envList.find((e) => e.id === sourceEnvId));
|
|
const sourceEnvName = $derived(sourceEnv?.name ?? '');
|
|
// Offer the "restore secrets from this backup" toggle whenever this is a stack
|
|
// snapshot that actually carries secrets — same UI for in-place and cross-env.
|
|
const showSecretRestore = $derived(targetIsStack && sourceSecretKeys.length > 0);
|
|
const postRestoreLabel = $derived(postRestoreOptions.find((o) => o.value === postRestore)?.label ?? '');
|
|
// Step-rail styling: always neutral (primary). Only the What-will-happen box
|
|
// turns red on the destructive (in-place) path — the rail/numbers stay calm.
|
|
// z-10 + solid dialog bg so the connector line is capped AT the circle edge and
|
|
// never shows through its (previously translucent) center.
|
|
const stepRingClass = 'relative z-10 border-primary/40 bg-background text-primary';
|
|
const stepLineClass = 'bg-primary/25';
|
|
|
|
// --- Exact target-path preview + host-data probe -----------------------------------------
|
|
// The EXACT on-disk targets the restore will write, resolved server-side by the SAME function
|
|
// the real restore uses (never client-side path math), plus whether each already holds data on
|
|
// the target host. Re-fetched (debounced) whenever the destination or target env changes.
|
|
type ProbeKind = 'has-data' | 'empty' | 'missing' | 'helper-failed';
|
|
interface TargetPreview {
|
|
volumes: Array<{ key: string; type: 'bind' | 'volume'; target: string; origin: string; hasData: ProbeKind }>;
|
|
stackFiles: { targetDir: string; willWrite: boolean; hasData: ProbeKind } | null;
|
|
unresolved: Array<{ key: string; reason: string }>;
|
|
helperOk: boolean;
|
|
helperError?: string;
|
|
}
|
|
let targetPreview = $state<TargetPreview | null>(null);
|
|
let targetPreviewLoading = $state(false);
|
|
// Set when the preview request itself fails (non-ok / thrown) so we surface a visible
|
|
// error instead of a silent empty section - a failed probe used to render as nothing.
|
|
let targetPreviewError = $state('');
|
|
// The user's acknowledgement that existing data at the resolved targets will be overwritten.
|
|
let overwriteAck = $state(false);
|
|
// Monotonic request id so a slow response for an old form state can't overwrite a newer one.
|
|
let targetPreviewSeq = 0;
|
|
let targetPreviewTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// A resolved target already holds data on the host (in-place is always destructive to it;
|
|
// a clone path/volume may be pre-populated). Drives the overwrite acknowledgement gate.
|
|
const targetsWithData = $derived(
|
|
(targetPreview?.volumes ?? []).filter((v) => v.hasData === 'has-data').length +
|
|
(targetPreview?.stackFiles?.hasData === 'has-data' ? 1 : 0)
|
|
);
|
|
const helperError = $derived(targetPreview && targetPreview.helperOk === false ? (targetPreview.helperError || 'the backup helper container could not run on the target environment') : '');
|
|
// Probe result per volume key (has-data / empty / missing), for the per-row badges in the
|
|
// "What will happen" recap. Null while the probe hasn't returned for that row yet -> the row
|
|
// shows its own spinner.
|
|
const probeByKey = $derived(new Map((targetPreview?.volumes ?? []).map((v) => [v.key, v.hasData])));
|
|
|
|
const selectedRows = $derived(volumes.filter((v) => v.selected));
|
|
// A selected volume redirected to a NEW name or type (vs the snapshot's original).
|
|
// The recreate/redeploy uses the STORED config/compose, which still references the
|
|
// original mounts — so it won't pick up the redirected data. We warn + default the
|
|
// post-restore action to 'none' so the target isn't started with the wrong data.
|
|
const remappedRows = $derived(selectedRows.filter(
|
|
(v) => v.dest.trim() !== v.origDest || v.destKind !== v.origDestKind
|
|
));
|
|
const hasRemap = $derived(mode === 'new-location' && remappedRows.length > 0);
|
|
// The clone will bring the target up (recreate a container / redeploy a stack).
|
|
const willBringUp = $derived(postRestore === 'recreate' || postRestore === 'redeploy');
|
|
// Target-name collision blocks only when we'd bring the target up — a name clash
|
|
// is harmless for a pure extract (postRestore 'none').
|
|
const nameConflict = $derived(mode === 'new-location' && willBringUp && nameExistsOnTarget);
|
|
// A blocking issue: a volume collision, an invalid host path, or a target-name clash.
|
|
const hasBlockingIssue = $derived(mode === 'new-location' && (nameConflict || selectedRows.some((v) => v.conflict || v.pathInvalid)));
|
|
const canRun = $derived(
|
|
// A target environment MUST be chosen.
|
|
effectiveEnvId != null &&
|
|
// Don't let a restore fire while the target-path probe is still running: it may come back
|
|
// with "has data" (an overwrite the user must acknowledge first), so running now would
|
|
// bypass that gate. Wait for the preview to resolve.
|
|
!targetPreviewLoading &&
|
|
!targetPreviewError &&
|
|
// The probe helper must be able to run on the target - if it can't, neither the probe
|
|
// nor the restore can, so block up front rather than fail mid-restore.
|
|
!helperError &&
|
|
(mode === 'in-place'
|
|
// In-place: confirmOverwrite IS the overwrite acknowledgement (its copy says
|
|
// "replaces the live volume data"). The new-location-only overwriteAck checkbox
|
|
// is never rendered here, so DON'T gate on it — that deadlocked the button whenever
|
|
// the live target already held data (targetsWithData > 0), i.e. every real in-place.
|
|
? confirmOverwrite
|
|
: (
|
|
// New-location: when any resolved target already holds data, the user must
|
|
// tick the separate overwrite acknowledgement.
|
|
(targetsWithData === 0 || overwriteAck) &&
|
|
// Every selected volume needs a non-empty destination; none may have a
|
|
// blocking issue (collision / invalid host path).
|
|
selectedRows.every((v) => v.dest.trim().length > 0) &&
|
|
!hasBlockingIssue &&
|
|
// Not a no-op: either something to restore, or a start action for a
|
|
// config-only snapshot (recreate/redeploy from stored config).
|
|
(selectedRows.length > 0 || postRestore !== 'none')
|
|
))
|
|
);
|
|
|
|
$effect(() => {
|
|
if (open && snapshotId) {
|
|
mode = 'new-location';
|
|
confirmOverwrite = false;
|
|
postRestore = 'recreate';
|
|
postRestoreUserPicked = false;
|
|
// Clone: NO env preselected — the user must consciously pick where it goes
|
|
// (the header shows no "to <env>" until they do). sourceEnvId is still seeded
|
|
// for in-place (which overwrites the live target on its own source env).
|
|
targetEnvId = undefined;
|
|
sourceEnvId = environmentId;
|
|
targetPath = `/restore/${containerName}`;
|
|
existingTargetVolumes = [];
|
|
// Reset stack-ness so a stale value from the previous open can't flash the
|
|
// wrong type icon while the new snapshot is still being read.
|
|
hasStackFiles = false;
|
|
skipStackFiles = false;
|
|
restoreStatus = 'idle';
|
|
restoreLogs = [];
|
|
restoreWarning = '';
|
|
error = '';
|
|
targetPreview = null;
|
|
overwriteAck = false;
|
|
if (envList.length === 0) environments.refresh();
|
|
void loadPreview();
|
|
}
|
|
});
|
|
|
|
// Re-check volume-name AND target-name conflicts whenever the target env changes
|
|
// (fail-closed: a named-volume destination OR the container/stack name already on
|
|
// the target blocks the clone).
|
|
$effect(() => {
|
|
const envId = effectiveEnvId;
|
|
// Track targetIsStack so the name check re-runs once the preview resolves the type.
|
|
const isStack = targetIsStack;
|
|
if (!open || mode !== 'new-location' || envId == null) { existingTargetVolumes = []; nameExistsOnTarget = false; return; }
|
|
void refreshTargetVolumes(envId);
|
|
void refreshTargetName(envId, isStack);
|
|
});
|
|
|
|
async function refreshTargetVolumes(envId: number) {
|
|
try {
|
|
const res = await fetch(`/api/volumes?env=${envId}`);
|
|
if (!res.ok) { existingTargetVolumes = []; return; }
|
|
const list = await res.json();
|
|
existingTargetVolumes = (Array.isArray(list) ? list : []).map((v: any) => v.Name ?? v.name).filter(Boolean);
|
|
} catch {
|
|
existingTargetVolumes = [];
|
|
}
|
|
}
|
|
|
|
// Does a container/stack named `containerName` already exist on the target env?
|
|
async function refreshTargetName(envId: number, isStack: boolean) {
|
|
try {
|
|
const path = isStack ? `/api/stacks?env=${envId}` : `/api/containers?env=${envId}`;
|
|
const res = await fetch(path);
|
|
if (!res.ok) { nameExistsOnTarget = false; return; }
|
|
const list = await res.json();
|
|
const names = (Array.isArray(list) ? list : []).map((x: any) => x.name ?? (Array.isArray(x.Names) ? x.Names[0]?.replace(/^\//, '') : undefined)).filter(Boolean);
|
|
nameExistsOnTarget = names.includes(containerName);
|
|
} catch {
|
|
nameExistsOnTarget = false;
|
|
}
|
|
}
|
|
|
|
// Switching a row between "Volume" (a named-volume) and "Host path" (a bind) —
|
|
// convert `dest` so a stale value from the other kind can't be submitted. A host
|
|
// path like `/docker/data/postgres18` is NOT a valid named-volume name (the server
|
|
// rejects it: validate.ts requires `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`), and vice-versa.
|
|
// path→volume: derive a name from the path's last segment; volume→path: prefix `/`.
|
|
// Empty when we can't convert cleanly, so the placeholder guides the user.
|
|
function onDestKindChange(vol: VolRow, next: 'volume' | 'path') {
|
|
if (vol.destKind === next) return;
|
|
const cur = vol.dest.trim();
|
|
if (next === 'volume') {
|
|
// From a host path → suggest the last path segment as a volume name, sanitised.
|
|
const base = cur.replace(/\/+$/, '').split('/').pop() ?? '';
|
|
const nameLike = base.replace(/[^a-zA-Z0-9_.-]/g, '');
|
|
vol.dest = /^[a-zA-Z0-9]/.test(nameLike) ? nameLike : '';
|
|
} else {
|
|
// From a volume name → an absolute path is required; only keep an already-
|
|
// absolute value, otherwise clear so the user types one.
|
|
vol.dest = cur.startsWith('/') ? cur : '';
|
|
}
|
|
vol.destKind = next;
|
|
}
|
|
|
|
// Mark each row's conflict flag: a named-volume destination that already exists
|
|
// on the target env. Bind (path) destinations are never a "conflict" (the user
|
|
// owns the host path). Recomputed whenever destinations or target volumes change.
|
|
$effect(() => {
|
|
const existing = existingTargetVolumes;
|
|
for (const v of volumes) {
|
|
const dest = v.dest.trim();
|
|
v.conflict = v.destKind === 'volume' && dest.length > 0 && existing.includes(dest);
|
|
// Match validate.ts exactly so the UI blocks a bad target BEFORE submit
|
|
// (previously only host paths were checked, so a volume kind with a path
|
|
// value like `/docker/...` slipped through to a generic 400).
|
|
v.pathInvalid = v.destKind === 'path'
|
|
? (dest.length === 0 || !dest.startsWith('/') || dest.includes('..'))
|
|
: !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(dest); // volume: must be a valid volume name
|
|
}
|
|
});
|
|
|
|
// Keep postRestore valid for the current mode (the option set differs: 'start'
|
|
// exists only in-place). On a mode switch, snap to the first allowed option.
|
|
$effect(() => {
|
|
if (!postRestoreOptions.some((o) => o.value === postRestore)) {
|
|
postRestore = postRestoreOptions[0]?.value as PostRestore;
|
|
}
|
|
});
|
|
|
|
// When the user redirects a volume to a new name/type, default the post-restore
|
|
// action to 'none' — bringing the target up would start it on the ORIGINAL mount,
|
|
// not the restored data. The user can still override (they may have already fixed
|
|
// their config); postRestoreUserPicked guards against fighting that choice.
|
|
$effect(() => {
|
|
if (hasRemap && !postRestoreUserPicked && postRestore !== 'none') postRestore = 'none';
|
|
});
|
|
|
|
async function loadPreview() {
|
|
loading = true;
|
|
error = '';
|
|
try {
|
|
const res = await fetch('/api/backup/restore/preview', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ destinationId, snapshotId, environmentId })
|
|
});
|
|
if (!res.ok) {
|
|
error = (await res.json()).error || 'Failed to read the snapshot';
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
const types: Record<string, 'volume' | 'bind'> = data.volumeTypes || {};
|
|
const sources: Record<string, string> = data.volumeSources || {};
|
|
// Pre-fill each destination 1:1 from the snapshot's original location: a
|
|
// named volume → its name (destKind 'volume'); a bind → its absolute host
|
|
// path (destKind 'path'). The user edits these to redirect the clone.
|
|
volumes = (data.volumes || []).map((name: string) => {
|
|
const type = types[name] ?? 'volume';
|
|
// destKind is 'volume' | 'path' — a bind source defaults to a host PATH,
|
|
// a named volume to a volume. (type is 'volume' | 'bind', not a destKind.)
|
|
const destKind: 'volume' | 'path' = type === 'bind' ? 'path' : 'volume';
|
|
const dest = sources[name] ?? name;
|
|
return { name, selected: true, type, dest, destKind, origDest: dest, origDestKind: destKind, conflict: false, pathInvalid: false };
|
|
});
|
|
hasMetadata = !!data.hasMetadata;
|
|
hasStackFiles = !!data.hasStackFiles;
|
|
backupTime = data.backupTime ?? null;
|
|
postRestore = data.hasStackFiles ? 'redeploy' : 'recreate';
|
|
// Recover the source env from the snapshot when the caller didn't supply one
|
|
// (empty prop) — needed for in-place (effectiveEnvId) and to default the
|
|
// clone target env. Only if that env still exists on this instance.
|
|
if (typeof data.sourceEnvironmentId === 'number' && envList.some((e) => e.id === data.sourceEnvironmentId)) {
|
|
// Only recover the SOURCE env (for in-place). The clone target env stays
|
|
// unselected on purpose — the user picks it.
|
|
if (sourceEnvId == null) sourceEnvId = data.sourceEnvironmentId;
|
|
}
|
|
sourceSecretKeys = Array.isArray(data.sourceSecretKeys) ? data.sourceSecretKeys : [];
|
|
} catch {
|
|
error = 'Failed to read the snapshot';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function loadTargetPreview() {
|
|
const envId = effectiveEnvId;
|
|
if (!open || !snapshotId || envId == null) { targetPreview = null; return; }
|
|
const seq = ++targetPreviewSeq;
|
|
targetPreviewLoading = true;
|
|
try {
|
|
const volumeDestinations = mode === 'new-location'
|
|
? selectedRows.map((v) => ({ volume: v.name, kind: v.destKind, target: v.dest.trim() }))
|
|
: undefined;
|
|
const res = await fetch('/api/backup/restore/preview', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
destinationId, snapshotId, mode, environmentId: envId,
|
|
targetType: targetIsStack ? 'stack' : 'container',
|
|
targetName: containerName, targetPath,
|
|
volumeDestinations, skipStackFiles,
|
|
volumes: selectedRows.map((v) => v.name),
|
|
})
|
|
});
|
|
if (seq !== targetPreviewSeq) return; // a newer request superseded this one
|
|
if (!res.ok) {
|
|
targetPreview = null;
|
|
const msg = await res.json().then((d) => d?.error).catch(() => null);
|
|
targetPreviewError = msg || 'Could not check the target paths on the environment.';
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
targetPreview = data.targets ?? null;
|
|
} catch {
|
|
if (seq === targetPreviewSeq) { targetPreview = null; targetPreviewError = 'Could not reach the server to check the target paths.'; }
|
|
} finally {
|
|
if (seq === targetPreviewSeq) targetPreviewLoading = false;
|
|
}
|
|
}
|
|
|
|
// Debounced re-probe on any change that moves where the restore lands. Reading these makes the
|
|
// effect track them; the 350ms debounce collapses a burst of keystrokes into one request.
|
|
$effect(() => {
|
|
// touch the reactive inputs so the effect re-runs when they change
|
|
void [mode, effectiveEnvId, skipStackFiles, targetIsStack,
|
|
selectedRows.map((v) => `${v.name}:${v.selected}:${v.destKind}:${v.dest}`).join('|')];
|
|
if (!open || !snapshotId) return;
|
|
// Clear the stale preview IMMEDIATELY (bump seq so any in-flight probe for the old target is
|
|
// discarded on return) so a warning/path for the previous env can't linger while the new
|
|
// probe runs - especially when the old env's helper was timing out (a slow pull).
|
|
targetPreviewSeq++;
|
|
targetPreview = null;
|
|
targetPreviewError = '';
|
|
if (targetPreviewTimer) clearTimeout(targetPreviewTimer);
|
|
// No target env picked yet (new-location before you choose one) -> nothing to probe. Clear
|
|
// the loading state and don't schedule a probe that has nowhere to mount.
|
|
if (effectiveEnvId == null) { targetPreviewLoading = false; return; }
|
|
targetPreviewLoading = true;
|
|
targetPreviewTimer = setTimeout(() => { void loadTargetPreview(); }, 350);
|
|
});
|
|
|
|
async function executeRestore() {
|
|
if (!canRun) return;
|
|
restoring = true;
|
|
error = '';
|
|
restoreStatus = 'running';
|
|
restoreLogs = [];
|
|
|
|
try {
|
|
// For a clone (new-location), every selected volume carries a destination.
|
|
const volumeDestinations = mode === 'new-location'
|
|
? selectedRows.map((v) => ({ volume: v.name, kind: v.destKind, target: v.dest.trim() }))
|
|
: undefined;
|
|
const res = await fetch('/api/backup/restore', {
|
|
method: 'POST',
|
|
// Ask for the job-polling/streaming path so the modal's log fills as the
|
|
// restore runs (Accept: application/json would take the synchronous path
|
|
// with no progress lines).
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
|
|
body: JSON.stringify({
|
|
destinationId,
|
|
snapshotId,
|
|
mode,
|
|
targetType: hasStackFiles ? 'stack' : 'container',
|
|
volumes: selectedRows.map((v) => v.name),
|
|
environmentId: effectiveEnvId,
|
|
targetName: containerName,
|
|
// Restore the stack's secrets carried in the snapshot. Only send false
|
|
// when the toggle is shown and the user turned it off; otherwise the
|
|
// server default (restore them) applies.
|
|
restoreSecrets: showSecretRestore ? restoreSecrets : undefined,
|
|
confirmOverwrite: mode === 'in-place' ? confirmOverwrite : undefined,
|
|
// postRestore is sent for BOTH modes now (clone brings the target up).
|
|
postRestore,
|
|
targetPath: mode === 'new-location' ? targetPath.trim() : undefined,
|
|
// New-location STACK restore options (server ignores them otherwise).
|
|
skipStackFiles: mode === 'new-location' && hasStackFiles ? skipStackFiles : undefined,
|
|
volumeDestinations
|
|
})
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
// Surface the specific validation reason(s), not just the generic
|
|
// "Invalid restore request" — e.g. "volume destination for X is not a
|
|
// valid volume name" tells the user exactly what to fix.
|
|
const issues = Array.isArray(body.issues)
|
|
? body.issues.map((i: { field?: string; message?: string }) => i.message).filter(Boolean)
|
|
: [];
|
|
error = issues.length
|
|
? `${body.error || 'Restore failed'}: ${issues.join('; ')}`
|
|
: (body.error || 'Restore failed');
|
|
restoreStatus = 'error';
|
|
restoring = false;
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
if (data.jobId) {
|
|
const result = (await watchJob(data.jobId, (line: any) => {
|
|
if (line.event === 'progress' && line.data?.message) {
|
|
restoreLogs = [...restoreLogs, tagLogLine(line.data.message)];
|
|
}
|
|
})) as any;
|
|
// Same discriminated-union handling as the backup path: a restore can come
|
|
// back 'skipped' (a concurrent op holds the lock) or 'warning' (data was
|
|
// restored but the post-restore recreate/redeploy failed) — neither is a
|
|
// plain success, and reporting them as one hides a half-finished restore.
|
|
const { outcome, message } = classifyJobResult(result);
|
|
if (outcome === 'error' || outcome === 'skipped') {
|
|
restoreStatus = 'error';
|
|
error = message || 'Restore failed';
|
|
restoreLogs = [...restoreLogs, tagLogLine(`[dockhand] Restore failed: ${error}`)];
|
|
} else if (outcome === 'warning') {
|
|
restoreStatus = 'warning';
|
|
restoreWarning = message || 'Restore completed with warnings';
|
|
restoreLogs = [...restoreLogs, tagLogLine(`[dockhand] Restore completed with warnings: ${restoreWarning}`)];
|
|
onDone?.(); // the data DID land — refresh the snapshot list
|
|
} else {
|
|
restoreStatus = 'success';
|
|
restoreLogs = [...restoreLogs, tagLogLine('[dockhand] Restore completed.')];
|
|
onDone?.();
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
restoreStatus = 'error';
|
|
error = e?.message || 'Restore failed';
|
|
restoreLogs = [...restoreLogs, tagLogLine(`[dockhand] Restore failed: ${error}`)];
|
|
} finally {
|
|
restoring = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<Dialog.Root bind:open>
|
|
<!-- Anchored near the top (not vertically centered) so toggling a volume or
|
|
switching modes grows the dialog DOWNWARD instead of jumping the whole box.
|
|
max-h + scroll keeps it usable on short screens. -->
|
|
<!-- Fixed height + internal scroll: the outer box never resizes, so switching
|
|
modes or checking/unchecking volumes changes only the scroll area, never the
|
|
dialog's top/bottom edges — no jumping. -->
|
|
<!-- Tall fixed height only for the multi-step FORM; the progress/result views
|
|
(log, success, warning, error) size to their content up to a cap so the log
|
|
dialog isn't a giant near-empty box. The tall form is top-anchored (top-[4vh])
|
|
so growing it downward doesn't jump; the short result views drop that override
|
|
and use the dialog's DEFAULT centered position — otherwise a one-line result
|
|
box sits glued to the top of the screen. -->
|
|
<Dialog.Content class="max-w-5xl flex flex-col overflow-hidden {restoreStatus === 'idle' ? 'top-[4vh] translate-y-0 h-[88vh]' : 'top-[6vh] translate-y-0 h-[85vh]'}">
|
|
<Dialog.Header class="shrink-0">
|
|
<Dialog.Title>
|
|
<SnapshotHeader
|
|
icon={RotateCcw}
|
|
verb="Restore"
|
|
name={containerName}
|
|
nameType={targetIsStack ? 'stack' : 'container'}
|
|
{destinationName}
|
|
{destinationRepository}
|
|
{sourceEnv}
|
|
{sourceEnvName}
|
|
{snapshotId}
|
|
snapshotTime={backupTime}
|
|
>
|
|
{#snippet trailing()}
|
|
{#if targetEnv}
|
|
<span class="text-muted-foreground">{mode === 'in-place' ? 'on' : 'to'}</span>
|
|
<span class="flex items-center gap-1 font-medium text-foreground"><EnvironmentIcon icon={targetEnv.icon || 'globe'} envId={targetEnv.id} class="h-4 w-4" />{targetEnvName}</span>
|
|
{/if}
|
|
{/snippet}
|
|
</SnapshotHeader>
|
|
</Dialog.Title>
|
|
<Dialog.Description class="sr-only">Restore snapshot {snapshotId.slice(0, 8)} for {containerName}.</Dialog.Description>
|
|
</Dialog.Header>
|
|
|
|
<!-- The single scroll region. flex-1 fills the fixed-height dialog between the
|
|
pinned header and footer; content growth scrolls here, not the outer box. -->
|
|
<div class="flex flex-1 flex-col overflow-y-auto -mx-6 px-6">
|
|
{#if loading}
|
|
<div class="flex flex-1 items-center justify-center text-muted-foreground">
|
|
<Loader2 class="h-5 w-5 animate-spin" /> <span class="ml-2">Reading snapshot…</span>
|
|
</div>
|
|
{:else if restoreStatus !== 'idle'}
|
|
<!-- Running AND finished states keep the LIVE LOG visible (no separate result
|
|
screen that hides what happened) — the outcome is appended as a final log
|
|
line and summarised in the status strip below, matching the backup dialog. -->
|
|
<!-- flex-1 + fixed dialog height: the log fills the space and SCROLLS
|
|
internally, so the dialog never grows/jumps as lines stream in. -->
|
|
<div class="flex min-h-0 flex-1 flex-col py-2">
|
|
<LogConsole lines={restoreLogs} class="flex-1 min-h-0" />
|
|
<div class="mt-2 flex shrink-0 items-center gap-1.5 text-sm">
|
|
{#if restoreStatus === 'running'}
|
|
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" /><span class="text-muted-foreground">Restoring…</span>
|
|
{:else if restoreStatus === 'success'}
|
|
<CheckCircle2 class="h-4 w-4 text-green-500" />
|
|
<span class="text-green-500">Restore completed</span>
|
|
<span class="text-muted-foreground">— {mode === 'new-location'
|
|
? `restored to ${targetEnvName}${postRestore !== 'none' ? (targetIsStack ? ', stack redeployed' : ', container recreated') : ''}`
|
|
: `live volume replaced — restart ${containerName}${hasStackFiles ? ' / redeploy the stack' : ''} to use it`}.</span>
|
|
{:else if restoreStatus === 'warning'}
|
|
<AlertTriangle class="h-4 w-4 text-amber-500" /><span class="text-amber-600 dark:text-amber-400">Restore completed with warnings — {restoreWarning}</span>
|
|
{:else}
|
|
<XCircle class="h-4 w-4 text-destructive" /><span class="text-destructive">{error || 'Restore failed'}</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="flex-1 py-2">
|
|
{#if error}
|
|
<p class="mb-3 rounded bg-destructive/10 p-2 text-sm text-destructive">{error}</p>
|
|
{/if}
|
|
|
|
<!-- STEP 1 — Where does it go? (mode + target env) -->
|
|
<div class="grid grid-cols-[26px_1fr] gap-x-3">
|
|
<div class="flex flex-col items-center">
|
|
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-bold {stepRingClass}">1</span>
|
|
<span class="-mb-3 w-0.5 flex-1 rounded {stepLineClass}"></span>
|
|
</div>
|
|
<div class="space-y-2 pb-3">
|
|
<div class="text-sm font-semibold">Where does it go? <span class="font-normal text-xs text-muted-foreground">— pick where, and how</span></div>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<button
|
|
type="button"
|
|
class="rounded border p-3 text-left text-sm {mode === 'new-location' ? 'border-primary bg-primary/5' : ''}"
|
|
onclick={() => (mode = 'new-location')}
|
|
>
|
|
<div class="flex items-center gap-1.5 font-medium"><Server class="h-3.5 w-3.5" /> To an environment</div>
|
|
<div class="mt-1 text-xs text-muted-foreground">Clone it onto a chosen environment.</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded border p-3 text-left text-sm {mode === 'in-place' ? 'border-primary bg-primary/5' : ''}"
|
|
onclick={() => (mode = 'in-place')}
|
|
>
|
|
<div class="flex items-center gap-1.5 font-medium"><AlertTriangle class="h-3.5 w-3.5 text-destructive" /> Overwrite live</div>
|
|
<div class="mt-1 text-xs text-muted-foreground">Replace the live data in place. Destructive.</div>
|
|
</button>
|
|
</div>
|
|
{#if mode === 'new-location'}
|
|
<div class="space-y-1.5">
|
|
<Label class="flex items-center gap-1.5"><Server class="h-3.5 w-3.5" /> Target environment</Label>
|
|
<Select.Root type="single" value={effectiveEnvId != null ? String(effectiveEnvId) : ''} onValueChange={(v) => (targetEnvId = v ? parseInt(v) : undefined)}>
|
|
<Select.Trigger class="h-9 w-full">
|
|
{#if targetEnv}
|
|
<span class="flex items-center gap-2"><EnvironmentIcon icon={targetEnv.icon || 'globe'} envId={targetEnv.id} class="h-4 w-4 text-muted-foreground" />{targetEnv.name}</span>
|
|
{:else}
|
|
<span class="text-muted-foreground">Select an environment…</span>
|
|
{/if}
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
{#each envList as env}
|
|
<Select.Item value={String(env.id)}>
|
|
<span class="flex items-center gap-2"><EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="h-4 w-4 text-muted-foreground" />{env.name}</span>
|
|
</Select.Item>
|
|
{/each}
|
|
</Select.Content>
|
|
</Select.Root>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- STEP 2 — What gets restored? (volumes + destinations) -->
|
|
<div class="grid grid-cols-[26px_1fr] gap-x-3">
|
|
<div class="flex flex-col items-center">
|
|
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-bold {stepRingClass}">2</span>
|
|
<span class="-mb-3 w-0.5 flex-1 rounded {stepLineClass}"></span>
|
|
</div>
|
|
<div class="space-y-2 pb-3">
|
|
<div class="flex items-center gap-1.5 text-sm font-semibold">
|
|
{mode === 'in-place' ? 'What gets overwritten?' : 'What gets restored?'}
|
|
<span class="font-normal text-xs text-muted-foreground">— {selectedRows.length} of {volumes.length} {volumes.length === 1 ? 'volume' : 'volumes'}</span>
|
|
{#if mode === 'new-location'}
|
|
<Tooltip.Root>
|
|
<Tooltip.Trigger class="ml-0.5"><HelpCircle class="h-3.5 w-3.5 text-muted-foreground opacity-70" /></Tooltip.Trigger>
|
|
<Tooltip.Content class="w-[22rem] max-w-[90vw]">
|
|
<p class="text-xs leading-relaxed">Each destination is resolved by <b>{targetEnvName || 'the target'}</b>'s Docker daemon: a <b>host path</b> must exist on that host (not on Dockhand's), while a <b>named volume</b> is created there. Prefer named volumes for portability. The post-restore step brings the {targetIsStack ? 'stack' : 'container'} up; if it fails, the data is still restored and you finish manually.</p>
|
|
</Tooltip.Content>
|
|
</Tooltip.Root>
|
|
{/if}
|
|
</div>
|
|
{#if volumes.length === 0}
|
|
<p class="text-sm text-muted-foreground">This snapshot has no volumes — restore recreates the {targetIsStack ? 'stack' : 'container'} from its saved config.</p>
|
|
{#if showSecretRestore || (mode === 'new-location' && targetIsStack && hasStackFiles)}
|
|
<div class="rounded border p-2">{@render secretRestoreBlock()}{@render stackRestoreOptions()}</div>
|
|
{/if}
|
|
{:else if mode === 'new-location'}
|
|
<!-- Clone mode: each selected volume maps to an editable DESTINATION on
|
|
the target env. Default is the original location (1:1 clone). -->
|
|
<div class="space-y-2 rounded border p-2">
|
|
{#each volumes as vol}
|
|
<div class="space-y-1">
|
|
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
|
<Checkbox bind:checked={vol.selected} />
|
|
{#if vol.type === 'bind'}
|
|
<Folder class="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
|
<span class="w-11 shrink-0 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-center text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">bind</span>
|
|
{:else}
|
|
<HardDrive class="h-3.5 w-3.5 shrink-0 text-sky-500" />
|
|
<span class="w-11 shrink-0 rounded-full bg-sky-500/15 px-1.5 py-0.5 text-center text-[10px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400">vol</span>
|
|
{/if}
|
|
<span class="truncate font-mono">{vol.name}</span>
|
|
</label>
|
|
{#if vol.selected}
|
|
<div class="flex items-center gap-1.5 pl-6">
|
|
<span class="text-xs text-muted-foreground">→</span>
|
|
<Select.Root type="single" value={vol.destKind} onValueChange={(v) => onDestKindChange(vol, v as 'volume' | 'path')}>
|
|
<Select.Trigger class="h-8 w-32 shrink-0 text-xs">
|
|
{#if vol.destKind === 'path'}
|
|
<span class="flex items-center gap-1.5"><Folder class="h-3.5 w-3.5 text-amber-500" /> Host path</span>
|
|
{:else}
|
|
<span class="flex items-center gap-1.5"><HardDrive class="h-3.5 w-3.5 text-sky-500" /> Volume</span>
|
|
{/if}
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
<Select.Item value="volume"><span class="flex items-center gap-1.5"><HardDrive class="h-3.5 w-3.5 text-sky-500" /> Volume</span></Select.Item>
|
|
<Select.Item value="path"><span class="flex items-center gap-1.5"><Folder class="h-3.5 w-3.5 text-amber-500" /> Host path</span></Select.Item>
|
|
</Select.Content>
|
|
</Select.Root>
|
|
<Input bind:value={vol.dest} class="h-8 flex-1 font-mono text-xs {(vol.conflict || vol.pathInvalid) ? 'border-destructive' : ''}" placeholder={vol.destKind === 'path' ? '/absolute/path' : 'volume-name'} />
|
|
</div>
|
|
{#if vol.conflict}
|
|
<p class="pl-6 text-xs text-destructive">Volume <span class="font-mono">{vol.dest}</span> already exists on {targetEnvName}. Remove it or choose another destination.</p>
|
|
{:else if vol.pathInvalid}
|
|
<p class="pl-6 text-xs text-destructive">A host path must be absolute — start it with <span class="font-mono">/</span> (e.g. <span class="font-mono">/srv/{vol.name}</span>).</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{@render secretRestoreBlock()}
|
|
{@render stackRestoreOptions()}
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-1.5 rounded border p-2">
|
|
{#each volumes as vol}
|
|
<label class="flex cursor-pointer items-start gap-2 text-sm">
|
|
<Checkbox bind:checked={vol.selected} class="mt-0.5" />
|
|
{#if vol.type === 'bind'}
|
|
<Folder class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
|
<span class="mt-0.5 w-11 shrink-0 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-center text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">bind</span>
|
|
{:else}
|
|
<HardDrive class="mt-0.5 h-3.5 w-3.5 shrink-0 text-sky-500" />
|
|
<span class="mt-0.5 w-11 shrink-0 rounded-full bg-sky-500/15 px-1.5 py-0.5 text-center text-[10px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400">vol</span>
|
|
{/if}
|
|
<span class="min-w-0">
|
|
<span class="block truncate font-mono">{vol.name}</span>
|
|
{#if vol.type === 'bind' && vol.origDest && vol.origDest !== vol.name}
|
|
<!-- The real host path that gets overwritten in place. -->
|
|
<span class="block break-all font-mono text-xs text-muted-foreground">{vol.origDest}</span>
|
|
{/if}
|
|
</span>
|
|
</label>
|
|
{/each}
|
|
{@render secretRestoreBlock()}
|
|
{@render stackRestoreOptions()}
|
|
</div>
|
|
{/if}
|
|
|
|
{@render hostTargetsBlock()}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- STEP 3 — Then what? (after-restore action) -->
|
|
<div class="grid grid-cols-[26px_1fr] gap-x-3">
|
|
<div class="flex flex-col items-center">
|
|
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-bold {stepRingClass}">3</span>
|
|
</div>
|
|
<div class="space-y-2 pb-1">
|
|
<div class="text-sm font-semibold">Then what? <span class="font-normal text-xs text-muted-foreground">— {mode === 'in-place' ? 'bring it back up' : 'bring it up on the target'}</span></div>
|
|
{#if mode === 'new-location'}
|
|
<!-- After restore: bring the target up on the chosen env. -->
|
|
<div class="space-y-1.5">
|
|
<Label class="sr-only">After restore</Label>
|
|
<Select.Root type="single" value={postRestore} onValueChange={(v) => { postRestore = v as PostRestore; postRestoreUserPicked = true; }}>
|
|
<Select.Trigger class="h-9">
|
|
{#each postRestoreOptions as opt}
|
|
{#if opt.value === postRestore}
|
|
{@const OptIcon = opt.icon}
|
|
<span class="flex items-center gap-2"><OptIcon class="h-3.5 w-3.5 text-muted-foreground" />{opt.label}</span>
|
|
{/if}
|
|
{/each}
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
{#each postRestoreOptions as opt}
|
|
{@const OptIcon = opt.icon}
|
|
<Select.Item value={opt.value}>
|
|
<span class="flex items-center gap-2"><OptIcon class="h-3.5 w-3.5 text-muted-foreground" />{opt.label}</span>
|
|
</Select.Item>
|
|
{/each}
|
|
</Select.Content>
|
|
</Select.Root>
|
|
{#if nameConflict}
|
|
<p class="text-xs text-destructive">A {targetIsStack ? 'stack' : 'container'} named <span class="font-mono">{containerName}</span> already exists on {targetEnvName}. Remove it or choose "Do nothing" — the restore won't overwrite it.</p>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-1.5">
|
|
<Label>After restore</Label>
|
|
<Select.Root type="single" value={postRestore} onValueChange={(v) => { postRestore = v as PostRestore; postRestoreUserPicked = true; }}>
|
|
<Select.Trigger class="h-9">
|
|
{#each postRestoreOptions as opt}
|
|
{#if opt.value === postRestore}
|
|
{@const OptIcon = opt.icon}
|
|
<span class="flex items-center gap-2"><OptIcon class="h-3.5 w-3.5 text-muted-foreground" />{opt.label}</span>
|
|
{/if}
|
|
{/each}
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
{#each postRestoreOptions as opt}
|
|
{@const OptIcon = opt.icon}
|
|
<Select.Item value={opt.value}>
|
|
<span class="flex items-center gap-2"><OptIcon class="h-3.5 w-3.5 text-muted-foreground" />{opt.label}</span>
|
|
</Select.Item>
|
|
{/each}
|
|
</Select.Content>
|
|
</Select.Root>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- What will happen: a live prose recap driven by the form. Env names use
|
|
the foreground colour (amber is reserved for warnings/destructive here),
|
|
each prefixed by the env's icon (same as the modal header). -->
|
|
<!-- The EXACT host targets the restore writes to, resolved server-side by the same
|
|
function the restore uses, plus whether each already holds data. Gives the user
|
|
certainty about what lands where before committing. -->
|
|
{#snippet hostDataBadge(kind: ProbeKind)}
|
|
{#if kind === 'has-data'}
|
|
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400"><AlertTriangle class="h-3 w-3" />has data</span>
|
|
{:else if kind === 'empty'}
|
|
<span class="shrink-0 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">empty</span>
|
|
{:else if kind === 'missing'}
|
|
<span class="shrink-0 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">new</span>
|
|
{/if}
|
|
{/snippet}
|
|
<!-- VOL / BIND kind pill + icon, used on BOTH sides of "<source> -> <target>" so a
|
|
volume-to-volume (or path-to-path) restore reads consistently, not "VOL -> named volume". -->
|
|
{#snippet kindBadge(kind: 'volume' | 'bind')}
|
|
{#if kind === 'bind'}
|
|
<Folder class="h-3 w-3 shrink-0 text-amber-500" />
|
|
<span class="w-9 shrink-0 rounded-full bg-amber-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">bind</span>
|
|
{:else}
|
|
<HardDrive class="h-3 w-3 shrink-0 text-sky-500" />
|
|
<span class="w-9 shrink-0 rounded-full bg-sky-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400">vol</span>
|
|
{/if}
|
|
{/snippet}
|
|
<!-- Only the FAILURE states live here now (they block the restore and must be seen even
|
|
when the "What will happen" recap can't render). The per-target paths + has-data
|
|
badges + overwrite ack moved INTO the recap so there's one place, not two. -->
|
|
{#snippet hostTargetsBlock()}
|
|
{#if helperError}
|
|
<div class="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-2.5 text-xs">
|
|
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
|
<div class="min-w-0">
|
|
<div class="font-medium text-destructive">The backup helper can't run on {targetEnvName || 'this environment'}</div>
|
|
<div class="mt-0.5 break-all text-muted-foreground">{helperError}</div>
|
|
<div class="mt-1 text-muted-foreground">A restore can't run until this is fixed - the same helper writes the restored data.</div>
|
|
</div>
|
|
</div>
|
|
{:else if targetPreviewError && !targetPreviewLoading}
|
|
<div class="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-2.5 text-xs">
|
|
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
|
<div class="min-w-0">
|
|
<div class="font-medium text-destructive">Couldn't check the target paths</div>
|
|
<div class="mt-0.5 break-all text-muted-foreground">{targetPreviewError}</div>
|
|
</div>
|
|
</div>
|
|
{:else if targetPreview && targetPreview.unresolved.length > 0}
|
|
<div class="space-y-1 rounded-md border border-destructive/50 bg-destructive/10 p-2.5 text-xs">
|
|
{#each targetPreview.unresolved as u}
|
|
<div class="flex items-start gap-2 text-destructive">
|
|
<Ban class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
<span class="min-w-0"><span class="font-mono">{u.key}</span> - {u.reason}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
{#snippet envChip(env: { id: number; icon?: string | null } | undefined, name: string)}
|
|
<span class="inline-flex -translate-y-[0.12em] items-center gap-1 align-middle font-medium text-foreground">{#if env}<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="h-3 w-3" />{/if}{name || '…'}</span>
|
|
{/snippet}
|
|
<!-- Secrets restored from the snapshot — rendered INSIDE the "what gets
|
|
restored" volume box so it reads as one coherent list. Ciphertext is
|
|
encrypted under this instance's key; restoring writes it to the target. -->
|
|
{#snippet secretRestoreBlock()}
|
|
{#if showSecretRestore}
|
|
<div class="space-y-2 border-t pt-2 {volumes.length > 0 ? 'mt-1.5' : ''}">
|
|
<label class="flex cursor-pointer items-start gap-2 text-sm">
|
|
<Checkbox bind:checked={restoreSecrets} class="mt-0.5" />
|
|
<KeyRound class="h-4 w-4 shrink-0 translate-y-0.5 text-amber-500" />
|
|
<span>
|
|
Restore {sourceSecretKeys.length} secret{sourceSecretKeys.length === 1 ? '' : 's'} from this backup.
|
|
<span class="block text-xs text-muted-foreground">
|
|
Turn off to bring the stack up without secrets and set them by hand.
|
|
</span>
|
|
</span>
|
|
</label>
|
|
<div class="flex flex-wrap gap-1 pl-6">
|
|
{#each sourceSecretKeys as key}
|
|
<code class="rounded bg-muted px-1.5 py-0.5 text-xs">{key}</code>
|
|
{/each}
|
|
</div>
|
|
{#if restoreSecrets}
|
|
<div class="flex items-start gap-1.5 pl-6 text-xs text-amber-600 dark:text-amber-500">
|
|
<AlertTriangle class="h-3.5 w-3.5 shrink-0 translate-y-0.5" />
|
|
<span>
|
|
Secrets are encrypted with this Dockhand instance's key. Restoring on a
|
|
different instance requires the same encryption key
|
|
(<code class="rounded bg-muted px-1 py-0.5">.encryption_key</code> /
|
|
<code class="rounded bg-muted px-1 py-0.5">ENCRYPTION_KEY</code>), or they
|
|
stay unreadable.
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
<!-- New-location STACK restore options: skip the captured compose/.env (data
|
|
only), and/or adopt the restored stack into Dockhand afterwards. Stack +
|
|
new-location only. -->
|
|
{#snippet stackRestoreOptions()}
|
|
{#if mode === 'new-location' && targetIsStack && hasStackFiles}
|
|
<!-- Top border/padding only when something (the secret block) sits above it;
|
|
otherwise this is the first content in the box and needs no divider. -->
|
|
<div class="space-y-2 {showSecretRestore ? 'border-t pt-2 mt-1.5' : ''}">
|
|
<label class="flex cursor-pointer items-start gap-2 text-sm">
|
|
<Checkbox bind:checked={skipStackFiles} class="mt-0.5" />
|
|
<FileX class="h-4 w-4 shrink-0 translate-y-0.5 text-muted-foreground" />
|
|
<span>
|
|
Restore volume data only (skip stack files)
|
|
<span class="block text-xs text-muted-foreground">
|
|
Leave out the captured compose and config - restore just the volume data. Otherwise the stack files are restored and registered in Dockhand so you can edit and redeploy the stack.
|
|
</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
<div class="mt-3 rounded-md border border-l-[3px] p-3 text-sm {mode === 'in-place' ? 'border-l-destructive bg-destructive/5' : 'border-l-primary bg-primary/5'}">
|
|
<div class="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{#if mode === 'in-place'}<AlertTriangle class="h-3.5 w-3.5 text-destructive" />{:else}<Info class="h-3.5 w-3.5 text-primary" />{/if}
|
|
What will happen
|
|
</div>
|
|
{#if mode === 'in-place'}
|
|
<p class="leading-relaxed">
|
|
The snapshot{#if backupTime} taken <span class="font-mono">{formatDateTime(backupTime)}</span>{/if}{#if sourceEnvName} from {@render envChip(sourceEnv, sourceEnvName)}{/if} will <b class="text-destructive">overwrite the live data</b>{#if targetEnvName} on {@render envChip(targetEnv, targetEnvName)}{/if}{#if selectedRows.length > 0}:{:else}.{/if}
|
|
</p>
|
|
{#if selectedRows.length > 0}
|
|
<ul class="mt-1.5 space-y-1">
|
|
{#each selectedRows as v}
|
|
{@const t = targetPreview?.volumes.find((x) => x.key === v.name)}
|
|
<li class="flex items-center gap-2 text-xs">
|
|
{#if v.type === 'bind'}<Folder class="h-3 w-3 shrink-0 text-amber-500" />{:else}<HardDrive class="h-3 w-3 shrink-0 text-sky-500" />{/if}
|
|
{#if v.type === 'bind'}<span class="w-9 shrink-0 rounded-full bg-amber-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">bind</span>{:else}<span class="w-9 shrink-0 rounded-full bg-sky-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400">vol</span>{/if}
|
|
<span class="font-mono">{v.name}</span>
|
|
{#if v.type === 'bind' && (t?.target ?? v.origDest)}
|
|
<span class="text-muted-foreground">→ host path</span>
|
|
<span class="min-w-0 break-all font-mono text-muted-foreground">{t?.target ?? v.origDest}</span>
|
|
{:else}
|
|
<span class="text-muted-foreground">wiped & replaced</span>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
<p class="mt-1.5 leading-relaxed">The {targetIsStack ? 'stack' : 'container'} is <b>stopped before the restore</b> (expect downtime); the volumes are swapped (staged then committed), then <b>{postRestoreLabel.toLowerCase()}</b>.</p>
|
|
<label class="mt-2.5 flex cursor-pointer items-center gap-2 border-t border-destructive/20 pt-2.5 text-sm">
|
|
<Checkbox bind:checked={confirmOverwrite} />
|
|
I understand this replaces the live volume data.
|
|
</label>
|
|
{:else}
|
|
<p class="leading-relaxed">
|
|
The snapshot{#if backupTime} taken <span class="font-mono">{formatDateTime(backupTime)}</span>{/if}{#if sourceEnvName} from {@render envChip(sourceEnv, sourceEnvName)}{/if} will be restored to {@render envChip(targetEnv, targetEnvName)}{#if selectedRows.length > 0}:{:else}.{/if}
|
|
</p>
|
|
{#if selectedRows.length > 0}
|
|
<ul class="mt-1.5 space-y-1">
|
|
{#each selectedRows as v}
|
|
{@const probe = probeByKey.get(v.name)}
|
|
<li class="flex items-center gap-2 text-xs">
|
|
{@render kindBadge(v.type)}
|
|
<span class="font-mono">{v.name}</span>
|
|
<span class="shrink-0 text-muted-foreground">→</span>
|
|
{@render kindBadge(v.destKind === 'path' ? 'bind' : 'volume')}
|
|
<span class="font-mono {(v.conflict || v.pathInvalid) ? 'text-destructive' : ''}">{v.dest.trim() || '…'}</span>
|
|
<!-- Per-row data probe: a small spinner pill while checking, then a badge. -->
|
|
{#if v.dest.trim()}
|
|
{#if probe}{@render hostDataBadge(probe)}
|
|
{:else if targetPreviewLoading}<span class="inline-flex shrink-0 items-center gap-1 rounded-full border border-border bg-background px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"><Loader2 class="h-2.5 w-2.5 animate-spin" />checking on{#if targetEnv}<EnvironmentIcon icon={targetEnv.icon || 'globe'} envId={targetEnv.id} class="h-3 w-3" />{/if}{targetEnvName || 'host'}</span>{/if}
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{#if targetsWithData > 0}
|
|
<label class="mt-2 flex cursor-pointer items-start gap-2 text-xs text-amber-600 dark:text-amber-400">
|
|
<Checkbox bind:checked={overwriteAck} class="mt-0.5" />
|
|
<span>I understand existing data at {targetsWithData === 1 ? 'this location' : `these ${targetsWithData} locations`} will be overwritten.</span>
|
|
</label>
|
|
{/if}
|
|
{/if}
|
|
{#if postRestore !== 'none'}
|
|
<p class="mt-1.5 leading-relaxed">Then Dockhand will <b>{postRestoreLabel.toLowerCase()}</b> on {@render envChip(targetEnv, targetEnvName)}{#if sourceEnvName && sourceEnvName !== targetEnvName}. Nothing on {@render envChip(sourceEnv, sourceEnvName)} is touched{/if}.</p>
|
|
{:else}
|
|
<p class="mt-1.5 leading-relaxed">The {targetIsStack ? 'stack' : 'container'} is <b>not started</b> — the data lands on {@render envChip(targetEnv, targetEnvName)} and you bring it up yourself.</p>
|
|
{/if}
|
|
{#if showSecretRestore}
|
|
<p class="mt-1.5 leading-relaxed">
|
|
{#if restoreSecrets}
|
|
Its <b>{sourceSecretKeys.length} secret{sourceSecretKeys.length === 1 ? '' : 's'}</b> will be restored from the backup.
|
|
{:else}
|
|
Its <b>{sourceSecretKeys.length} secret{sourceSecretKeys.length === 1 ? '' : 's'}</b> will <b>not</b> be restored — set {sourceSecretKeys.length === 1 ? 'it' : 'them'} by hand afterwards.
|
|
{/if}
|
|
</p>
|
|
{/if}
|
|
{#if hasRemap}
|
|
<!-- The recreate/redeploy uses the snapshot's STORED config/compose, which
|
|
still references the ORIGINAL mounts — it won't follow a redirected
|
|
name/type, so the target would come up on the wrong (empty) volume. -->
|
|
<div class="mt-2.5 rounded-md border border-l-[3px] border-amber-500/30 border-l-amber-500 bg-amber-500/10 p-2.5">
|
|
<div class="mb-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">
|
|
<AlertTriangle class="h-3.5 w-3.5" /> You have changed a volume's name or type
|
|
</div>
|
|
<p class="text-xs leading-relaxed text-amber-700 dark:text-amber-300/90">Your data goes to the new volumes. But the {targetIsStack ? 'stack redeploys from the stored compose file, which still names' : 'recreated container mounts'} the <b>original</b> ones — so it {targetIsStack ? 'starts with empty volumes' : "won't see the restored data"}.</p>
|
|
<p class="mt-1 text-xs leading-relaxed text-amber-700 dark:text-amber-300/90"><b>After restoring:</b> {targetIsStack ? 'update the compose file to the new volume names, then redeploy.' : 'edit the container and point the mount at the new volume.'}</p>
|
|
{#if postRestore === 'none'}<p class="mt-1 text-[11px] leading-relaxed text-amber-600/70 dark:text-amber-300/60">Next step set to <b>Do nothing</b> so it can't start with the wrong data.</p>{/if}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<Dialog.Footer class="shrink-0">
|
|
{#if restoreStatus === 'success' || restoreStatus === 'warning' || restoreStatus === 'error'}
|
|
<Button variant="outline" onclick={() => (open = false)}>OK</Button>
|
|
{:else if restoreStatus !== 'running'}
|
|
<Button variant="outline" onclick={() => (open = false)} disabled={restoring}>Cancel</Button>
|
|
<!-- Hide the restore action until the snapshot is read — until then we
|
|
don't know its volumes/target, so there's nothing to restore yet. -->
|
|
{#if !loading}
|
|
<Button
|
|
onclick={executeRestore}
|
|
disabled={!canRun || restoring}
|
|
variant={mode === 'in-place' ? 'destructive' : 'default'}
|
|
>
|
|
{#if restoring}<Loader2 class="mr-1.5 h-4 w-4 animate-spin" />{:else}<Play class="mr-1.5 h-4 w-4" />{/if}
|
|
{mode === 'in-place' ? 'Overwrite & restore' : (postRestore !== 'none' ? 'Restore & start' : 'Restore')}
|
|
</Button>
|
|
{/if}
|
|
{/if}
|
|
</Dialog.Footer>
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|