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:
menzelj
2026-06-07 20:58:05 +00:00
co-authored by Claude Opus 4.8
parent 22d9864436
commit 8d19b09abd
30 changed files with 2034 additions and 71 deletions
+4 -1
View File
@@ -8,7 +8,9 @@ import { StackDetail } from "@/pages/StackDetail";
import { StackEditor } from "@/pages/StackEditor";
import { Images } from "@/pages/Images";
import { Templates } from "@/pages/Templates";
import { Networks, Settings } from "@/pages/Placeholder";
import { Settings } from "@/pages/Settings";
import { Audit } from "@/pages/Audit";
import { Networks } from "@/pages/Placeholder";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
@@ -44,6 +46,7 @@ export default function App() {
<Route path="/networks" element={<Networks />} />
<Route path="/images" element={<Images />} />
<Route path="/templates" element={<Templates />} />
<Route path="/audit" element={<Audit />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Route>
+45
View File
@@ -0,0 +1,45 @@
import api from "./client";
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export const backupsApi = {
download: async (
stackId: string,
opts: { includeVolumes: boolean; stopFirst: boolean }
) => {
const res = await api.get(`/api/stacks/${stackId}/backup`, {
params: { include_volumes: opts.includeVolumes, stop_first: opts.stopFirst },
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);
},
restore: async (
file: File,
opts: { targetId?: string; overwrite: boolean; restoreVolumes: 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);
return res.data;
},
};
+56
View File
@@ -0,0 +1,56 @@
import api from "./client";
import type { User } from "@/types";
export interface AppSettings {
update_check_interval_minutes: number;
env_webhook_count: number;
available_events: string[];
webhook_types: string[];
}
export interface Webhook {
id: number;
name: string;
url: string;
type: string;
events: string[];
enabled: boolean;
created_at: string;
}
export interface WebhookInput {
name: string;
url: string;
type: string;
events: string[];
enabled: boolean;
}
export const settingsApi = {
get: () => api.get<AppSettings>("/api/settings").then((r) => r.data),
update: (body: { update_check_interval_minutes?: number }) =>
api.put<AppSettings>("/api/settings", body).then((r) => r.data),
listWebhooks: () =>
api.get<Webhook[]>("/api/settings/webhooks").then((r) => r.data),
createWebhook: (body: WebhookInput) =>
api.post<Webhook>("/api/settings/webhooks", body).then((r) => r.data),
updateWebhook: (id: number, body: Partial<WebhookInput>) =>
api.put<Webhook>(`/api/settings/webhooks/${id}`, body).then((r) => r.data),
deleteWebhook: (id: number) =>
api.delete(`/api/settings/webhooks/${id}`).then((r) => r.data),
testWebhook: (id: number) =>
api.post<{ ok: boolean }>(`/api/settings/webhooks/${id}/test`).then((r) => r.data),
};
export const usersApi = {
list: () => api.get<User[]>("/api/auth/users").then((r) => r.data),
create: (body: { username: string; password: string; role: string }) =>
api.post<User>("/api/auth/users", body).then((r) => r.data),
update: (
id: number,
body: { password?: string; role?: string; is_active?: boolean }
) => api.patch<User>(`/api/auth/users/${id}`, body).then((r) => r.data),
remove: (id: number) =>
api.delete(`/api/auth/users/${id}`).then((r) => r.data),
};
+6 -3
View File
@@ -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>
+90 -54
View File
@@ -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>
</>
);
}
+11 -2
View File
@@ -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>
)}
</>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ScrollText } from "lucide-react";
import { Card, Input, Spinner } from "@/components/ui";
import api from "@/api/client";
import type { AuditEntry } from "@/types";
import { relativeTime } from "@/lib/utils";
const PAGE = 100;
export function Audit() {
const [offset, setOffset] = useState(0);
const [filter, setFilter] = useState("");
const { data, isLoading, isFetching } = useQuery({
queryKey: ["audit-log", offset],
queryFn: () =>
api
.get<AuditEntry[]>(`/api/audit?limit=${PAGE}&offset=${offset}`)
.then((r) => r.data),
refetchInterval: 15000,
});
const rows = (data ?? []).filter((a) => {
if (!filter.trim()) return true;
const q = filter.toLowerCase();
return (
a.user.toLowerCase().includes(q) ||
a.action.toLowerCase().includes(q) ||
a.target.toLowerCase().includes(q)
);
});
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<ScrollText className="h-4 w-4" /> Audit log
</h2>
<Input
placeholder="Filter by user, action, or target…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="max-w-xs"
/>
</div>
<Card className="overflow-x-auto p-0">
{isLoading ? (
<Spinner />
) : (
<table className="w-full text-left text-sm">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500 dark:border-slate-700">
<tr>
<th className="px-4 py-2">When</th>
<th className="px-4 py-2">User</th>
<th className="px-4 py-2">Action</th>
<th className="px-4 py-2">Target</th>
<th className="px-4 py-2">Detail</th>
<th className="px-4 py-2">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{rows.map((a) => (
<tr key={a.id}>
<td className="whitespace-nowrap px-4 py-2 text-xs text-slate-400">
{relativeTime(a.timestamp)}
</td>
<td className="px-4 py-2 font-medium">{a.user}</td>
<td className="px-4 py-2 text-slate-500">{a.action}</td>
<td className="px-4 py-2 font-mono text-xs text-accent dark:text-accent-dark">
{a.target}
</td>
<td className="px-4 py-2 text-xs text-slate-500">{a.detail}</td>
<td className="px-4 py-2 font-mono text-xs text-slate-400">{a.ip}</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-slate-500">
No matching activity.
</td>
</tr>
)}
</tbody>
</table>
)}
</Card>
<div className="flex items-center justify-between text-sm">
<button
className="rounded-lg px-3 py-1.5 text-slate-600 enabled:hover:bg-slate-100 disabled:opacity-40 dark:text-slate-300 dark:enabled:hover:bg-slate-700"
disabled={offset === 0 || isFetching}
onClick={() => setOffset((o) => Math.max(0, o - PAGE))}
>
Newer
</button>
<span className="text-xs text-slate-400">
Showing {offset + 1}{offset + (data?.length ?? 0)}
</span>
<button
className="rounded-lg px-3 py-1.5 text-slate-600 enabled:hover:bg-slate-100 disabled:opacity-40 dark:text-slate-300 dark:enabled:hover:bg-slate-700"
disabled={(data?.length ?? 0) < PAGE || isFetching}
onClick={() => setOffset((o) => o + PAGE)}
>
Older
</button>
</div>
</div>
);
}
+1 -2
View File
@@ -14,5 +14,4 @@ export function Placeholder({ title, phase }: { title: string; phase: string })
);
}
export const Networks = () => <Placeholder title="Networks" phase="Phase 4" />;
export const Settings = () => <Placeholder title="Settings" phase="Phase 4" />;
export const Networks = () => <Placeholder title="Networks" phase="a future phase" />;
+458
View File
@@ -0,0 +1,458 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Bell,
Clock,
Plus,
Send,
Trash2,
Users as UsersIcon,
ShieldCheck,
Power,
} from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
import {
settingsApi,
usersApi,
type Webhook,
type WebhookInput,
} from "@/api/settings";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import type { User } from "@/types";
export function Settings() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
if (!isAdmin) {
return (
<Card className="flex flex-col items-center gap-3 py-16 text-center">
<ShieldCheck className="h-10 w-10 text-slate-400" />
<h2 className="text-lg font-semibold">Admin only</h2>
<p className="max-w-md text-sm text-slate-500">
Settings are available to administrators only.
</p>
</Card>
);
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<GeneralSection />
<NotificationsSection />
<UsersSection />
</div>
);
}
/* -------------------------------------------------------------------------- */
/* General */
/* -------------------------------------------------------------------------- */
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
{icon}
{children}
</h2>
);
}
function GeneralSection() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["settings"], queryFn: settingsApi.get });
const [interval, setInterval] = useState("");
useEffect(() => {
if (data) setInterval(String(data.update_check_interval_minutes));
}, [data]);
const save = useMutation({
mutationFn: () =>
settingsApi.update({ update_check_interval_minutes: Number(interval) }),
onSuccess: () => {
toast.success("Settings saved");
qc.invalidateQueries({ queryKey: ["settings"] });
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<section>
<SectionTitle icon={<Clock className="h-4 w-4" />}>General</SectionTitle>
<Card className="space-y-4">
{isLoading ? (
<Spinner />
) : (
<>
<label className="block space-y-1">
<span className="text-sm font-medium">Image update check interval (minutes)</span>
<div className="flex gap-2">
<Input
type="number"
min={5}
value={interval}
onChange={(e) => setInterval(e.target.value)}
className="max-w-[140px]"
/>
<Button onClick={() => save.mutate()} loading={save.isPending}>
Save
</Button>
</div>
<span className="text-xs text-slate-500">
Minimum 5 minutes. Applies on the next check cycle.
</span>
</label>
{data && data.env_webhook_count > 0 && (
<p className="text-xs text-slate-500">
{data.env_webhook_count} generic webhook(s) configured via the{" "}
<code>NOTIFY_WEBHOOKS</code> environment variable receive every event.
</p>
)}
</>
)}
</Card>
</section>
);
}
/* -------------------------------------------------------------------------- */
/* Notifications */
/* -------------------------------------------------------------------------- */
const EVENT_LABELS: Record<string, string> = {
update_available: "Image update available",
stack_start: "Stack started",
stack_stop: "Stack stopped",
stack_error: "Stack error",
pull_failed: "Pull/update failed",
};
function NotificationsSection() {
const qc = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: settingsApi.get });
const webhooks = useQuery({ queryKey: ["webhooks"], queryFn: settingsApi.listWebhooks });
const [adding, setAdding] = useState(false);
return (
<section>
<SectionTitle icon={<Bell className="h-4 w-4" />}>Notifications</SectionTitle>
<div className="space-y-3">
{webhooks.isLoading ? (
<Spinner />
) : (
webhooks.data?.map((w) => <WebhookRow key={w.id} webhook={w} />)
)}
{webhooks.data?.length === 0 && !adding && (
<Card>
<p className="text-sm text-slate-500">
No webhooks yet. Add ntfy, Discord, Slack, Gotify, or a generic JSON endpoint.
</p>
</Card>
)}
{adding && settings.data && (
<WebhookForm
types={settings.data.webhook_types}
events={settings.data.available_events}
onDone={() => {
setAdding(false);
qc.invalidateQueries({ queryKey: ["webhooks"] });
}}
onCancel={() => setAdding(false)}
/>
)}
{!adding && (
<Button variant="outline" onClick={() => setAdding(true)}>
<Plus className="h-4 w-4" /> Add webhook
</Button>
)}
</div>
</section>
);
}
function WebhookRow({ webhook }: { webhook: Webhook }) {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ["webhooks"] });
const toggle = useMutation({
mutationFn: () => settingsApi.updateWebhook(webhook.id, { enabled: !webhook.enabled }),
onSuccess: invalidate,
onError: (e) => toast.error(apiErrorMessage(e)),
});
const remove = useMutation({
mutationFn: () => settingsApi.deleteWebhook(webhook.id),
onSuccess: () => {
toast.success("Webhook deleted");
invalidate();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
const test = useMutation({
mutationFn: () => settingsApi.testWebhook(webhook.id),
onSuccess: (r) =>
r.ok ? toast.success("Test sent") : toast.error("Delivery failed — check the URL"),
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<Card className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-medium">{webhook.name}</span>
<Badge>{webhook.type}</Badge>
{!webhook.enabled && <span className="text-xs text-slate-400">disabled</span>}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => test.mutate()} loading={test.isPending}>
<Send className="h-4 w-4" /> Test
</Button>
<Button variant="ghost" onClick={() => toggle.mutate()} loading={toggle.isPending}>
<Power className="h-4 w-4" /> {webhook.enabled ? "Disable" : "Enable"}
</Button>
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
<p className="break-all font-mono text-xs text-slate-500">{webhook.url}</p>
<div className="flex flex-wrap gap-1">
{webhook.events.map((e) => (
<span
key={e}
className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300"
>
{EVENT_LABELS[e] ?? e}
</span>
))}
</div>
</Card>
);
}
function WebhookForm({
types,
events,
onDone,
onCancel,
}: {
types: string[];
events: string[];
onDone: () => void;
onCancel: () => void;
}) {
const [form, setForm] = useState<WebhookInput>({
name: "",
url: "",
type: types[0] ?? "generic",
events: [...events],
enabled: true,
});
const create = useMutation({
mutationFn: () => settingsApi.createWebhook(form),
onSuccess: () => {
toast.success("Webhook added");
onDone();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
const toggleEvent = (e: string) =>
setForm((f) => ({
...f,
events: f.events.includes(e) ? f.events.filter((x) => x !== e) : [...f.events, e],
}));
return (
<Card className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Name</span>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Type</span>
<select
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"
value={form.type}
onChange={(e) => setForm({ ...form, type: e.target.value })}
>
{types.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
</div>
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">URL</span>
<Input
placeholder="https://ntfy.sh/my-topic"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
/>
</label>
<div className="space-y-1">
<span className="text-xs font-medium text-slate-500">Events</span>
<div className="flex flex-wrap gap-2">
{events.map((e) => (
<button
key={e}
type="button"
onClick={() => toggleEvent(e)}
className={
form.events.includes(e)
? "rounded-full bg-accent px-2 py-0.5 text-xs text-white dark:bg-accent-dark dark:text-slate-900"
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300"
}
>
{EVENT_LABELS[e] ?? e}
</button>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button
onClick={() => create.mutate()}
loading={create.isPending}
disabled={!form.name.trim() || !form.url.trim() || form.events.length === 0}
>
Add
</Button>
</div>
</Card>
);
}
/* -------------------------------------------------------------------------- */
/* Users */
/* -------------------------------------------------------------------------- */
function UsersSection() {
const qc = useQueryClient();
const me = useAuthStore((s) => s.user);
const { data, isLoading } = useQuery({ queryKey: ["users"], queryFn: usersApi.list });
const [adding, setAdding] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: ["users"] });
return (
<section>
<SectionTitle icon={<UsersIcon className="h-4 w-4" />}>Users</SectionTitle>
<Card className="space-y-2">
{isLoading ? (
<Spinner />
) : (
<ul className="divide-y divide-slate-100 dark:divide-slate-700">
{data?.map((u) => (
<UserRow key={u.id} user={u} isSelf={u.id === me?.id} onChange={invalidate} />
))}
</ul>
)}
{adding ? (
<AddUserForm onDone={() => { setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} />
) : (
<Button variant="outline" className="mt-2" onClick={() => setAdding(true)}>
<Plus className="h-4 w-4" /> Add user
</Button>
)}
</Card>
</section>
);
}
function UserRow({ user, isSelf, onChange }: { user: User; isSelf: boolean; onChange: () => void }) {
const update = useMutation({
mutationFn: (body: { role?: string; is_active?: boolean }) => usersApi.update(user.id, body),
onSuccess: onChange,
onError: (e) => toast.error(apiErrorMessage(e)),
});
const remove = useMutation({
mutationFn: () => usersApi.remove(user.id),
onSuccess: () => { toast.success("User removed"); onChange(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<li className="flex flex-wrap items-center justify-between gap-2 py-2">
<div className="flex items-center gap-2">
<span className="font-medium">{user.username}</span>
<Badge>{user.role}</Badge>
{!user.is_active && <span className="text-xs text-red-500">inactive</span>}
{isSelf && <span className="text-xs text-slate-400">you</span>}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={() => update.mutate({ role: user.role === "admin" ? "user" : "admin" })}
loading={update.isPending}
>
{user.role === "admin" ? "Make user" : "Make admin"}
</Button>
<Button
variant="ghost"
onClick={() => update.mutate({ is_active: !user.is_active })}
loading={update.isPending}
>
{user.is_active ? "Disable" : "Enable"}
</Button>
{!isSelf && (
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
</li>
);
}
function AddUserForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("user");
const create = useMutation({
mutationFn: () => usersApi.create({ username, password, role }),
onSuccess: () => { toast.success("User created"); onDone(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<div className="mt-2 space-y-2 rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="grid gap-2 sm:grid-cols-3">
<Input placeholder="username" value={username} onChange={(e) => setUsername(e.target.value)} />
<Input
type="password"
placeholder="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<select
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"
value={role}
onChange={(e) => setRole(e.target.value)}
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button
onClick={() => create.mutate()}
loading={create.isPending}
disabled={!username.trim() || !password}
>
Create
</Button>
</div>
</div>
);
}
+2
View File
@@ -12,6 +12,7 @@ import {
} from "lucide-react";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { LogViewer } from "@/components/stacks/LogViewer";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
@@ -67,6 +68,7 @@ export function StackDetail() {
<Button variant="outline" onClick={() => actions.down(id)} loading={busy}>
<Power className="h-4 w-4" /> Down
</Button>
<BackupButton stackId={id} />
<Link to={`/stacks/${id}/edit`}>
<Button>
<Pencil className="h-4 w-4" /> Edit
+2
View File
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { Plus, Search } from "lucide-react";
import { Button, Input, Spinner, Card } from "@/components/ui";
import { StackCard } from "@/components/stacks/StackCard";
import { RestoreButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
@@ -57,6 +58,7 @@ export function Stacks() {
<option value="status">Sort: Status</option>
<option value="updated">Sort: Last updated</option>
</select>
{isAdmin && <RestoreButton />}
{isAdmin && (
<Link to="/stacks/new">
<Button>