- Backup/restore: per-stack tar.gz incl. named-volume snapshots (helper container), upload restore with rename/overwrite/conflict detection. - Notifications: ntfy/Discord/Slack/Gotify/generic webhooks, per-event subscriptions; wired into the update checker and stack lifecycle. - Settings page: update-check interval, webhook CRUD + test, user management (with last-admin safeguards). - Audit log page (searchable, paginated). - Mobile-responsive sidebar/layout. Multi-host agents and remote backup destinations (SFTP/S3) deferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
6.2 KiB
TypeScript
193 lines
6.2 KiB
TypeScript
import { useState } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { Archive, Upload } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@/components/ui";
|
|
import { backupsApi } from "@/api/backups";
|
|
import { apiErrorMessage } from "@/api/client";
|
|
|
|
function Checkbox({
|
|
checked,
|
|
onChange,
|
|
label,
|
|
hint,
|
|
}: {
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
label: string;
|
|
hint?: string;
|
|
}) {
|
|
return (
|
|
<label className="flex items-start gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-accent"
|
|
/>
|
|
<span>
|
|
{label}
|
|
{hint && <span className="block text-xs text-slate-500">{hint}</span>}
|
|
</span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
|
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"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function BackupButton({ stackId }: { stackId: string }) {
|
|
const [open, setOpen] = useState(false);
|
|
const [includeVolumes, setIncludeVolumes] = useState(true);
|
|
const [stopFirst, setStopFirst] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const run = async () => {
|
|
setBusy(true);
|
|
const tid = toast.loading("Creating backup…");
|
|
try {
|
|
await backupsApi.download(stackId, { includeVolumes, stopFirst });
|
|
toast.success("Backup downloaded", { id: tid });
|
|
setOpen(false);
|
|
} catch (e) {
|
|
toast.error(apiErrorMessage(e), { id: tid });
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Button variant="outline" onClick={() => setOpen(true)}>
|
|
<Archive className="h-4 w-4" /> Backup
|
|
</Button>
|
|
{open && (
|
|
<Modal onClose={() => !busy && setOpen(false)}>
|
|
<h2 className="mb-3 text-lg font-semibold">Back up “{stackId}”</h2>
|
|
<div className="space-y-3">
|
|
<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."
|
|
/>
|
|
</div>
|
|
<div className="mt-4 flex justify-end gap-2">
|
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={run} loading={busy}>
|
|
Download backup
|
|
</Button>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function RestoreButton() {
|
|
const qc = useQueryClient();
|
|
const [open, setOpen] = useState(false);
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [targetId, setTargetId] = useState("");
|
|
const [overwrite, setOverwrite] = useState(false);
|
|
const [restoreVolumes, setRestoreVolumes] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const run = async () => {
|
|
if (!file) {
|
|
toast.error("Select a backup file");
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
const tid = toast.loading("Restoring…");
|
|
try {
|
|
const res = await backupsApi.restore(file, {
|
|
targetId: targetId.trim() || undefined,
|
|
overwrite,
|
|
restoreVolumes,
|
|
});
|
|
toast.success(
|
|
`Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`,
|
|
{ id: tid }
|
|
);
|
|
qc.invalidateQueries({ queryKey: ["stacks"] });
|
|
setOpen(false);
|
|
setFile(null);
|
|
setTargetId("");
|
|
} catch (e) {
|
|
toast.error(apiErrorMessage(e), { id: tid });
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Button variant="outline" onClick={() => setOpen(true)}>
|
|
<Upload className="h-4 w-4" /> Restore
|
|
</Button>
|
|
{open && (
|
|
<Modal onClose={() => !busy && setOpen(false)}>
|
|
<h2 className="mb-3 text-lg font-semibold">Restore from backup</h2>
|
|
<div className="space-y-3">
|
|
<input
|
|
type="file"
|
|
accept=".tar.gz,.tgz,application/gzip"
|
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
|
className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-accent file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-accent-dark dark:file:text-slate-900"
|
|
/>
|
|
<label className="block space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">
|
|
Restore as (optional — leave blank to use the original name)
|
|
</span>
|
|
<input
|
|
value={targetId}
|
|
onChange={(e) => setTargetId(e.target.value)}
|
|
placeholder="new-stack-name"
|
|
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
|
/>
|
|
</label>
|
|
<Checkbox
|
|
checked={restoreVolumes}
|
|
onChange={setRestoreVolumes}
|
|
label="Restore volume data"
|
|
/>
|
|
<Checkbox
|
|
checked={overwrite}
|
|
onChange={setOverwrite}
|
|
label="Overwrite if a stack with this id already exists"
|
|
hint="Replaces the existing stack files and volume contents."
|
|
/>
|
|
</div>
|
|
<div className="mt-4 flex justify-end gap-2">
|
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={run} loading={busy} disabled={!file}>
|
|
Restore
|
|
</Button>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</>
|
|
);
|
|
}
|