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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user