Phase 4: backups w/ volumes, notifications, settings & users, audit page (0.4.0)
- 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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
22d9864436
commit
8d19b09abd
@@ -1,14 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Topbar } from "./Topbar";
|
||||
|
||||
export function Layout() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-bg text-slate-900 dark:bg-bg-dark dark:text-slate-100">
|
||||
<Sidebar />
|
||||
<Sidebar mobileOpen={mobileOpen} onClose={() => setMobileOpen(false)} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Topbar onMenu={() => setMobileOpen(true)} />
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
Network,
|
||||
Image,
|
||||
LayoutTemplate,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Moon,
|
||||
Sun,
|
||||
LogOut,
|
||||
Ship,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
@@ -21,72 +23,106 @@ const nav = [
|
||||
{ to: "/networks", label: "Networks", icon: Network },
|
||||
{ to: "/images", label: "Images", icon: Image },
|
||||
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
|
||||
{ to: "/audit", label: "Audit log", icon: ScrollText },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
export function Sidebar({
|
||||
mobileOpen = false,
|
||||
onClose,
|
||||
}: {
|
||||
mobileOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const { theme, toggle } = useThemeStore();
|
||||
|
||||
return (
|
||||
<aside className="flex w-60 flex-col border-r border-slate-200 bg-card dark:border-slate-700 dark:bg-card-dark">
|
||||
<div className="flex items-center gap-2 px-5 py-5">
|
||||
<Ship className="h-7 w-7 text-accent dark:text-accent-dark" />
|
||||
<span className="text-lg font-bold">StackPilot</span>
|
||||
</div>
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-black/50 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{nav.map(({ to, label, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent/10 text-accent dark:bg-accent-dark/10 dark:text-accent-dark"
|
||||
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-200 p-3 dark:border-slate-700">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{user?.username ?? "—"}
|
||||
{user?.role === "admin" && (
|
||||
<span className="ml-1 text-xs text-accent dark:text-accent-dark">
|
||||
admin
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<aside
|
||||
className={cn(
|
||||
"z-40 flex w-60 flex-col border-r border-slate-200 bg-card dark:border-slate-700 dark:bg-card-dark",
|
||||
// Off-canvas on mobile, static on desktop.
|
||||
"fixed inset-y-0 left-0 transform transition-transform md:static md:translate-x-0",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ship className="h-7 w-7 text-accent dark:text-accent-dark" />
|
||||
<span className="text-lg font-bold">StackPilot</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
title="Toggle theme"
|
||||
onClick={onClose}
|
||||
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 md:hidden"
|
||||
title="Close menu"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{nav.map(({ to, label, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
onClick={onClose}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent/10 text-accent dark:bg-accent-dark/10 dark:text-accent-dark"
|
||||
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-200 p-3 dark:border-slate-700">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{user?.username ?? "—"}
|
||||
{user?.role === "admin" && (
|
||||
<span className="ml-1 text-xs text-accent dark:text-accent-dark">
|
||||
admin
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Menu } from "lucide-react";
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
"": "Dashboard",
|
||||
@@ -6,16 +7,24 @@ const titles: Record<string, string> = {
|
||||
networks: "Networks",
|
||||
images: "Images",
|
||||
templates: "Templates",
|
||||
audit: "Audit log",
|
||||
settings: "Settings",
|
||||
};
|
||||
|
||||
export function Topbar() {
|
||||
export function Topbar({ onMenu }: { onMenu?: () => void }) {
|
||||
const { pathname } = useLocation();
|
||||
const segment = pathname.split("/")[1] ?? "";
|
||||
const title = titles[segment] ?? "StackPilot";
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b border-slate-200 bg-card px-6 dark:border-slate-700 dark:bg-card-dark">
|
||||
<header className="flex h-14 items-center gap-3 border-b border-slate-200 bg-card px-4 dark:border-slate-700 dark:bg-card-dark sm:px-6">
|
||||
<button
|
||||
onClick={onMenu}
|
||||
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 md:hidden"
|
||||
title="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="text-base font-semibold">{title}</h1>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user