Back up bind-mount data, not just the compose file (0.40.0)
A stack's real state lives in its bind-mounted config directories, and those
were never captured: the backup only tarred the stack folder as this container
sees it. When STACKS_HOST_DIR differs from the container's STACKS_DIR, compose
resolves ./config against the container path and the daemon creates it at that
path on the *host* — invisible here, so the archive held little more than
compose.yaml and .env.
New services/stack_assets_service.py inventories a stack's data (bind sources
merged from container mounts + the compose file, named volumes) and does all
data I/O through a throwaway helper container, i.e. by host path, so unseen
directories are captured anyway. It also detects the host/container stacks-path
mismatch and reports it.
- manifest v2: full inventory, per-asset capture result, skip reasons (v1 still
restores)
- NFS/CIFS-backed volumes are skipped by default and never wiped on restore
- deselected data inside the stack folder no longer sneaks in via compose/
- volume/bind archives stream through temp files instead of RAM
- restore preserves mode, ownership, mtime and symlinks, and writes bind folders
back to their host paths (rewritten when the stack is renamed)
- backup dialog shows the inventory with sizes and per-item checkboxes; restore
gained a "restore bind folders" toggle
- new GET /api/stacks/{id}/backup/inventory (+ agent + proxy), backup endpoints
take include_binds/binds/volumes, restore takes restore_binds
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.39.0",
|
||||
"version": "0.40.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+27
-16
@@ -1,4 +1,6 @@
|
||||
import api from "./client";
|
||||
import { backupParams, readReport } from "./backups";
|
||||
import type { BackupInventory, BackupOptions, RestoreResult } from "./backups";
|
||||
import type { Agent, StackDetail, StackStats, StackSummary, StackUpdateInfo } from "@/types";
|
||||
|
||||
export type RemoteStackSummary = StackSummary & { agent_id: number; agent_name: string };
|
||||
@@ -54,13 +56,13 @@ export const agentsApi = {
|
||||
action: (id: number, stackId: string, action: string) =>
|
||||
api.post(`/api/agents/${id}/stacks/${stackId}/${action}`).then((r) => r.data),
|
||||
|
||||
backupDownload: async (
|
||||
id: number,
|
||||
stackId: string,
|
||||
opts: { includeVolumes: boolean; stopFirst: boolean }
|
||||
) => {
|
||||
backupInventory: (id: number, stackId: string) =>
|
||||
api
|
||||
.get<BackupInventory>(`/api/agents/${id}/stacks/${stackId}/backup/inventory`)
|
||||
.then((r) => r.data),
|
||||
backupDownload: async (id: number, stackId: string, opts: BackupOptions) => {
|
||||
const res = await api.get(`/api/agents/${id}/stacks/${stackId}/backup`, {
|
||||
params: { include_volumes: opts.includeVolumes, stop_first: opts.stopFirst },
|
||||
params: backupParams(opts),
|
||||
responseType: "blob",
|
||||
});
|
||||
const cd = res.headers["content-disposition"] as string | undefined;
|
||||
@@ -73,11 +75,19 @@ export const agentsApi = {
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
return readReport(res.headers);
|
||||
},
|
||||
backupPush: (
|
||||
id: number,
|
||||
stackId: string,
|
||||
body: { destination_id: number; include_volumes: boolean; stop_first: boolean }
|
||||
body: {
|
||||
destination_id: number;
|
||||
include_volumes: boolean;
|
||||
include_binds?: boolean;
|
||||
stop_first: boolean;
|
||||
binds?: string[];
|
||||
volumes?: string[];
|
||||
}
|
||||
) =>
|
||||
api
|
||||
.post<{ ok: boolean; destination: string; name: string }>(
|
||||
@@ -88,18 +98,21 @@ export const agentsApi = {
|
||||
restoreUpload: (
|
||||
id: number,
|
||||
file: File,
|
||||
opts: { targetId?: string; overwrite: boolean; restoreVolumes: boolean }
|
||||
opts: {
|
||||
targetId?: string;
|
||||
overwrite: boolean;
|
||||
restoreVolumes: boolean;
|
||||
restoreBinds?: boolean;
|
||||
}
|
||||
) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (opts.targetId) form.append("target_id", opts.targetId);
|
||||
form.append("overwrite", String(opts.overwrite));
|
||||
form.append("restore_volumes", String(opts.restoreVolumes));
|
||||
form.append("restore_binds", String(opts.restoreBinds ?? true));
|
||||
return api
|
||||
.post<{ stack_id: string; name: string; volumes_restored: number }>(
|
||||
`/api/agents/${id}/stacks/restore`,
|
||||
form
|
||||
)
|
||||
.post<RestoreResult>(`/api/agents/${id}/stacks/restore`, form)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
restoreFrom: (
|
||||
@@ -110,12 +123,10 @@ export const agentsApi = {
|
||||
target_id?: string;
|
||||
overwrite: boolean;
|
||||
restore_volumes: boolean;
|
||||
restore_binds?: boolean;
|
||||
}
|
||||
) =>
|
||||
api
|
||||
.post<{ stack_id: string; name: string; volumes_restored: number }>(
|
||||
`/api/agents/${id}/stacks/restore-from`,
|
||||
body
|
||||
)
|
||||
.post<RestoreResult>(`/api/agents/${id}/stacks/restore-from`, body)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
+113
-23
@@ -8,6 +8,87 @@ export interface BackupDestination {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** One bind-mount source or named volume a backup would capture. */
|
||||
export interface BackupBind {
|
||||
source: string;
|
||||
mounts: { service: string; target: string }[];
|
||||
kind: string;
|
||||
size: number | null;
|
||||
inside_stack_dir: boolean;
|
||||
covered_by_compose: boolean;
|
||||
via: "compose" | "archive";
|
||||
system: boolean;
|
||||
include_default: boolean;
|
||||
selected: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface BackupVolume {
|
||||
name: string;
|
||||
short: string;
|
||||
driver: string;
|
||||
remote: boolean;
|
||||
remote_type: string | null;
|
||||
include_default: boolean;
|
||||
selected: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface BackupInventory {
|
||||
stack_id: string;
|
||||
stack_dir: string;
|
||||
stack_dir_visible: boolean;
|
||||
path_mismatch: { host: string; container: string } | null;
|
||||
binds: BackupBind[];
|
||||
volumes: BackupVolume[];
|
||||
}
|
||||
|
||||
export interface BackupReport {
|
||||
size: number | null;
|
||||
binds: { source: string; bytes: number | null; error: string | null }[];
|
||||
volumes: { name: string; bytes: number | null; error: string | null }[];
|
||||
skipped: { kind: string; source?: string; name?: string; reason: string | null }[];
|
||||
path_mismatch: { host: string; container: string } | null;
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
stack_id: string;
|
||||
name: string;
|
||||
volumes_restored: number;
|
||||
binds_restored?: number;
|
||||
skipped?: { kind: string; source?: string; name?: string; reason: string | null }[];
|
||||
}
|
||||
|
||||
export interface BackupOptions {
|
||||
includeVolumes: boolean;
|
||||
stopFirst: boolean;
|
||||
includeBinds?: boolean;
|
||||
binds?: string[];
|
||||
volumes?: string[];
|
||||
}
|
||||
|
||||
/** Query params shared by the local and the agent-proxied backup endpoints. */
|
||||
export function backupParams(opts: BackupOptions) {
|
||||
return {
|
||||
include_volumes: opts.includeVolumes,
|
||||
include_binds: opts.includeBinds ?? true,
|
||||
stop_first: opts.stopFirst,
|
||||
...(opts.binds ? { binds: opts.binds } : {}),
|
||||
...(opts.volumes ? { volumes: opts.volumes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The backup summary the server attaches to the download response. */
|
||||
export function readReport(headers: unknown): BackupReport | null {
|
||||
const raw = (headers as Record<string, string> | undefined)?.["x-stackpilot-backup"];
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as BackupReport;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoteBackup {
|
||||
name: string;
|
||||
size: number;
|
||||
@@ -26,46 +107,60 @@ function triggerDownload(blob: Blob, filename: string) {
|
||||
}
|
||||
|
||||
export const backupsApi = {
|
||||
download: async (
|
||||
stackId: string,
|
||||
opts: { includeVolumes: boolean; stopFirst: boolean }
|
||||
) => {
|
||||
inventory: (stackId: string) =>
|
||||
api
|
||||
.get<BackupInventory>(`/api/stacks/${stackId}/backup/inventory`)
|
||||
.then((r) => r.data),
|
||||
|
||||
download: async (stackId: string, opts: BackupOptions) => {
|
||||
const res = await api.get(`/api/stacks/${stackId}/backup`, {
|
||||
params: { include_volumes: opts.includeVolumes, stop_first: opts.stopFirst },
|
||||
params: backupParams(opts),
|
||||
responseType: "blob",
|
||||
});
|
||||
const cd = res.headers["content-disposition"] as string | undefined;
|
||||
const match = cd?.match(/filename="?([^"]+)"?/);
|
||||
const name = match?.[1] ?? `backup-${stackId}.tar.gz`;
|
||||
triggerDownload(res.data as Blob, name);
|
||||
return readReport(res.headers);
|
||||
},
|
||||
|
||||
restore: async (
|
||||
file: File,
|
||||
opts: { targetId?: string; overwrite: boolean; restoreVolumes: boolean }
|
||||
opts: {
|
||||
targetId?: string;
|
||||
overwrite: boolean;
|
||||
restoreVolumes: boolean;
|
||||
restoreBinds?: boolean;
|
||||
}
|
||||
) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (opts.targetId) form.append("target_id", opts.targetId);
|
||||
form.append("overwrite", String(opts.overwrite));
|
||||
form.append("restore_volumes", String(opts.restoreVolumes));
|
||||
const res = await api.post<{
|
||||
stack_id: string;
|
||||
name: string;
|
||||
volumes_restored: number;
|
||||
}>("/api/stacks/restore", form);
|
||||
form.append("restore_binds", String(opts.restoreBinds ?? true));
|
||||
const res = await api.post<RestoreResult>("/api/stacks/restore", form);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
push: (
|
||||
stackId: string,
|
||||
body: { destination_id: number; include_volumes: boolean; stop_first: boolean }
|
||||
body: {
|
||||
destination_id: number;
|
||||
include_volumes: boolean;
|
||||
include_binds?: boolean;
|
||||
stop_first: boolean;
|
||||
binds?: string[];
|
||||
volumes?: string[];
|
||||
}
|
||||
) =>
|
||||
api
|
||||
.post<{ ok: boolean; destination: string; name: string }>(
|
||||
`/api/stacks/${stackId}/backup/push`,
|
||||
body
|
||||
)
|
||||
.post<{
|
||||
ok: boolean;
|
||||
destination: string;
|
||||
name: string;
|
||||
report?: BackupReport;
|
||||
}>(`/api/stacks/${stackId}/backup/push`, body)
|
||||
.then((r) => r.data),
|
||||
|
||||
restoreFrom: (body: {
|
||||
@@ -74,13 +169,8 @@ export const backupsApi = {
|
||||
target_id?: string;
|
||||
overwrite: boolean;
|
||||
restore_volumes: boolean;
|
||||
}) =>
|
||||
api
|
||||
.post<{ stack_id: string; name: string; volumes_restored: number }>(
|
||||
"/api/stacks/restore-from",
|
||||
body
|
||||
)
|
||||
.then((r) => r.data),
|
||||
restore_binds?: boolean;
|
||||
}) => api.post<RestoreResult>("/api/stacks/restore-from", body).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const destinationsApi = {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Archive, Upload } from "lucide-react";
|
||||
import { AlertTriangle, Archive, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui";
|
||||
import { backupsApi, destinationsApi } from "@/api/backups";
|
||||
import type { BackupReport } from "@/api/backups";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
@@ -38,11 +39,21 @@ function Checkbox({
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
function Modal({
|
||||
children,
|
||||
onClose,
|
||||
wide,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark"
|
||||
className={`max-h-[85vh] w-full overflow-auto rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark ${
|
||||
wide ? "max-w-xl" : "max-w-md"
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
@@ -51,6 +62,61 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
|
||||
);
|
||||
}
|
||||
|
||||
function AssetRow({
|
||||
checked,
|
||||
onChange,
|
||||
title,
|
||||
detail,
|
||||
size,
|
||||
badges,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
title: string;
|
||||
detail?: string;
|
||||
size?: number | null;
|
||||
badges?: { text: string; tone?: "muted" | "warn" }[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-start gap-2 rounded-lg px-2 py-1.5 hover:bg-slate-50 dark:hover:bg-slate-800/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 shrink-0 rounded border-slate-300 text-accent disabled:opacity-40"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate font-mono text-xs text-slate-700 dark:text-slate-200">{title}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-slate-400">
|
||||
{size != null ? formatBytes(size) : ""}
|
||||
</span>
|
||||
</span>
|
||||
{detail && <span className="block truncate text-[11px] text-slate-500">{detail}</span>}
|
||||
{badges && badges.length > 0 && (
|
||||
<span className="mt-0.5 flex flex-wrap gap-1">
|
||||
{badges.map((b) => (
|
||||
<span
|
||||
key={b.text}
|
||||
className={`rounded-chip px-1.5 py-0.5 text-[10px] ${
|
||||
b.tone === "warn"
|
||||
? "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
|
||||
: "bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{b.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackupButton({
|
||||
stackId,
|
||||
agentId,
|
||||
@@ -59,39 +125,75 @@ export function BackupButton({
|
||||
agentId?: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [includeVolumes, setIncludeVolumes] = useState(true);
|
||||
const [stopFirst, setStopFirst] = useState(true);
|
||||
const [target, setTarget] = useState("download"); // "download" | destination id
|
||||
const [busy, setBusy] = useState(false);
|
||||
// null = "use the inventory defaults" (until the user touches a checkbox).
|
||||
const [pickedBinds, setPickedBinds] = useState<string[] | null>(null);
|
||||
const [pickedVolumes, setPickedVolumes] = useState<string[] | null>(null);
|
||||
|
||||
const destinations = useQuery({
|
||||
queryKey: ["destinations"],
|
||||
queryFn: destinationsApi.list,
|
||||
enabled: open,
|
||||
});
|
||||
const inventory = useQuery({
|
||||
queryKey: ["backup-inventory", agentId ?? "local", stackId],
|
||||
queryFn: () =>
|
||||
agentId != null
|
||||
? agentsApi.backupInventory(agentId, stackId)
|
||||
: backupsApi.inventory(stackId),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const binds = inventory.data?.binds ?? [];
|
||||
const volumes = inventory.data?.volumes ?? [];
|
||||
const bindSel = pickedBinds ?? binds.filter((b) => b.include_default).map((b) => b.source);
|
||||
const volSel = pickedVolumes ?? volumes.filter((v) => v.include_default).map((v) => v.name);
|
||||
const toggle = (list: string[], value: string, on: boolean) =>
|
||||
on ? [...list, value] : list.filter((v) => v !== value);
|
||||
|
||||
const describe = (report: BackupReport | null | undefined) => {
|
||||
if (!report) return "Backup created";
|
||||
const parts = [`${report.binds.length} folder(s)`, `${report.volumes.length} volume(s)`];
|
||||
if (report.size) parts.push(formatBytes(report.size));
|
||||
const skipped = report.skipped.filter((s) => s.reason !== "not requested").length;
|
||||
return `Backup: ${parts.join(" · ")}${skipped ? ` — ${skipped} skipped` : ""}`;
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
const tid = toast.loading("Creating backup…");
|
||||
try {
|
||||
const opts = {
|
||||
includeVolumes: volSel.length > 0,
|
||||
includeBinds: bindSel.length > 0,
|
||||
stopFirst,
|
||||
// Empty lists would be dropped from the query string and read as
|
||||
// "use defaults", so the include_* flags carry that case.
|
||||
binds: bindSel.length ? bindSel : undefined,
|
||||
volumes: volSel.length ? volSel : undefined,
|
||||
};
|
||||
if (target === "download") {
|
||||
if (agentId != null) {
|
||||
await agentsApi.backupDownload(agentId, stackId, { includeVolumes, stopFirst });
|
||||
} else {
|
||||
await backupsApi.download(stackId, { includeVolumes, stopFirst });
|
||||
}
|
||||
toast.success("Backup downloaded", { id: tid });
|
||||
const report =
|
||||
agentId != null
|
||||
? await agentsApi.backupDownload(agentId, stackId, opts)
|
||||
: await backupsApi.download(stackId, opts);
|
||||
toast.success(describe(report), { id: tid });
|
||||
} else {
|
||||
const body = {
|
||||
destination_id: Number(target),
|
||||
include_volumes: includeVolumes,
|
||||
include_volumes: opts.includeVolumes,
|
||||
include_binds: opts.includeBinds,
|
||||
stop_first: stopFirst,
|
||||
binds: opts.binds,
|
||||
volumes: opts.volumes,
|
||||
};
|
||||
const res =
|
||||
agentId != null
|
||||
? await agentsApi.backupPush(agentId, stackId, body)
|
||||
: await backupsApi.push(stackId, body);
|
||||
toast.success(`Backup pushed to ${res.destination}`, { id: tid });
|
||||
toast.success(`Pushed to ${res.destination}`, { id: tid });
|
||||
}
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
@@ -107,9 +209,75 @@ export function BackupButton({
|
||||
<Archive className="h-4 w-4" /> Backup
|
||||
</Button>
|
||||
{open && (
|
||||
<Modal onClose={() => !busy && setOpen(false)}>
|
||||
<Modal wide onClose={() => !busy && setOpen(false)}>
|
||||
<h2 className="mb-3 sp-heading text-lg">Back up “{stackId}”</h2>
|
||||
<div className="space-y-3">
|
||||
{inventory.data?.path_mismatch && (
|
||||
<div className="flex gap-2 rounded-lg bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
This stack's folder is <code>{inventory.data.path_mismatch.container}</code> inside
|
||||
StackPilot but <code>{inventory.data.path_mismatch.host}</code> on the host, so the
|
||||
containers' data directories are not visible here. They are read through a helper
|
||||
container and are listed below.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-slate-500">Contents</div>
|
||||
<div className="rounded-lg border border-slate-200 p-1 dark:border-slate-700">
|
||||
<AssetRow
|
||||
checked
|
||||
disabled
|
||||
onChange={() => {}}
|
||||
title={inventory.data?.stack_dir ?? "stack folder"}
|
||||
detail="Compose file, .env and everything else in the stack folder"
|
||||
badges={[{ text: "always included" }]}
|
||||
/>
|
||||
{inventory.isLoading && (
|
||||
<div className="px-2 py-2 text-xs text-slate-500">Scanning stack data…</div>
|
||||
)}
|
||||
{binds.map((b) => (
|
||||
<AssetRow
|
||||
key={b.source}
|
||||
checked={bindSel.includes(b.source)}
|
||||
disabled={b.system || b.kind === "special"}
|
||||
onChange={(v) => setPickedBinds(toggle(bindSel, b.source, v))}
|
||||
title={b.source}
|
||||
detail={b.mounts.map((m) => `${m.service}:${m.target}`).join(", ")}
|
||||
size={b.size}
|
||||
badges={[
|
||||
...(b.via === "compose" ? [{ text: "in stack folder" as const }] : []),
|
||||
...(b.reason ? [{ text: b.reason, tone: "warn" as const }] : []),
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
{volumes.map((v) => (
|
||||
<AssetRow
|
||||
key={v.name}
|
||||
checked={volSel.includes(v.name)}
|
||||
onChange={(on) => setPickedVolumes(toggle(volSel, v.name, on))}
|
||||
title={v.name}
|
||||
detail={`named volume (${v.driver})`}
|
||||
badges={[
|
||||
...(v.remote ? [{ text: v.remote_type || "remote", tone: "warn" as const }] : []),
|
||||
...(v.reason && !v.remote ? [{ text: v.reason, tone: "warn" as const }] : []),
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
{!inventory.isLoading && binds.length === 0 && volumes.length === 0 && (
|
||||
<div className="px-2 py-2 text-xs text-slate-500">
|
||||
No bind mounts or named volumes found for this stack.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
Remote storage (NFS/CIFS) is unchecked by default — it lives on your NAS and would be
|
||||
overwritten on restore.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Destination</span>
|
||||
<select className={selectClass} value={target} onChange={(e) => setTarget(e.target.value)}>
|
||||
@@ -121,17 +289,11 @@ export function BackupButton({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={includeVolumes}
|
||||
onChange={setIncludeVolumes}
|
||||
label="Include named volume data"
|
||||
hint="Snapshots each compose-managed volume into the archive."
|
||||
/>
|
||||
<Checkbox
|
||||
checked={stopFirst}
|
||||
onChange={setStopFirst}
|
||||
label="Stop the stack during backup"
|
||||
hint="Recommended for a consistent volume snapshot; the stack is restarted afterwards."
|
||||
hint="Recommended for a consistent snapshot; the stack is restarted afterwards."
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
@@ -158,6 +320,7 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [overwrite, setOverwrite] = useState(false);
|
||||
const [restoreVolumes, setRestoreVolumes] = useState(true);
|
||||
const [restoreBinds, setRestoreBinds] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const destinations = useQuery({
|
||||
@@ -182,7 +345,12 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
const opts = { targetId: targetId.trim() || undefined, overwrite, restoreVolumes };
|
||||
const opts = {
|
||||
targetId: targetId.trim() || undefined,
|
||||
overwrite,
|
||||
restoreVolumes,
|
||||
restoreBinds,
|
||||
};
|
||||
res =
|
||||
agentId != null
|
||||
? await agentsApi.restoreUpload(agentId, file, opts)
|
||||
@@ -199,13 +367,16 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
|
||||
target_id: targetId.trim() || undefined,
|
||||
overwrite,
|
||||
restore_volumes: restoreVolumes,
|
||||
restore_binds: restoreBinds,
|
||||
};
|
||||
res =
|
||||
agentId != null
|
||||
? await agentsApi.restoreFrom(agentId, body)
|
||||
: await backupsApi.restoreFrom(body);
|
||||
}
|
||||
toast.success(`Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`, { id: tid });
|
||||
const bits = [`${res.volumes_restored} volume(s)`];
|
||||
if (res.binds_restored) bits.push(`${res.binds_restored} folder(s)`);
|
||||
toast.success(`Restored '${res.stack_id}' — ${bits.join(", ")}`, { id: tid });
|
||||
qc.invalidateQueries({ queryKey: agentId != null ? ["agent-stacks", agentId] : ["stacks"] });
|
||||
setOpen(false);
|
||||
setFile(null);
|
||||
@@ -305,6 +476,12 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
|
||||
/>
|
||||
</label>
|
||||
<Checkbox checked={restoreVolumes} onChange={setRestoreVolumes} label="Restore volume data" />
|
||||
<Checkbox
|
||||
checked={restoreBinds}
|
||||
onChange={setRestoreBinds}
|
||||
label="Restore bind-mounted folders"
|
||||
hint="Writes the captured config folders back to their host paths."
|
||||
/>
|
||||
<Checkbox
|
||||
checked={overwrite}
|
||||
onChange={setOverwrite}
|
||||
|
||||
Reference in New Issue
Block a user