- Agent: GET /agent/stacks/{id}/backup + POST /agent/stacks/restore (reuse
backup_service). backup_service gains backup_basename/backup_filename helpers.
- Main proxy streams agent <-> main <-> destination (creds stay central):
agent_service download_to_file/upload_file; routers/agents.py backup download,
backup/push, restore upload, restore-from.
- Schedules: BackupSchedule.agent_id; schedule_service downloads from the agent
when set; per-host filename prefix isolates retention across hosts.
- Frontend: agents api backup/restore; BackupButton/RestoreButton agent-aware
(Backup on remote stack detail, Restore per host section); schedule form host
selector (local or an online agent) + host shown on schedule rows.
Rough-verified (per request): py_compile, frontend tsc build, image imports
(main 99 / agent 16 routes). Full live agent round-trip to be tested post-deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1018 lines
37 KiB
TypeScript
1018 lines
37 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Bell,
|
|
Clock,
|
|
Plus,
|
|
Send,
|
|
Trash2,
|
|
Users as UsersIcon,
|
|
ShieldCheck,
|
|
Power,
|
|
Server,
|
|
RefreshCw,
|
|
HardDrive,
|
|
CalendarClock,
|
|
Play,
|
|
} 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 { destinationsApi, type BackupDestination } from "@/api/backups";
|
|
import { schedulesApi, type BackupSchedule } from "@/api/schedules";
|
|
import { stacksApi } from "@/api/stacks";
|
|
import { agentsApi } from "@/api/agents";
|
|
import { HostDot } from "@/components/hosts/HostDot";
|
|
import { apiErrorMessage } from "@/api/client";
|
|
import { relativeTime } from "@/lib/utils";
|
|
import { useAuthStore } from "@/store/auth";
|
|
import type { Agent, 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 />
|
|
<HostsSection />
|
|
<DestinationsSection />
|
|
<SchedulesSection />
|
|
<NotificationsSection />
|
|
<UsersSection />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Scheduled backups */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
|
|
function describeSchedule(s: BackupSchedule): string {
|
|
const t = `${String(s.hour).padStart(2, "0")}:${String(s.minute).padStart(2, "0")} UTC`;
|
|
if (s.frequency === "hourly") return `Hourly at :${String(s.minute).padStart(2, "0")}`;
|
|
if (s.frequency === "weekly") return `Weekly · ${WEEKDAYS[s.weekday] ?? "?"} ${t}`;
|
|
return `Daily · ${t}`;
|
|
}
|
|
|
|
function SchedulesSection() {
|
|
const qc = useQueryClient();
|
|
const { data, isLoading } = useQuery({ queryKey: ["schedules"], queryFn: schedulesApi.list });
|
|
const destinations = useQuery({ queryKey: ["destinations"], queryFn: destinationsApi.list });
|
|
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list });
|
|
const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list() });
|
|
const [adding, setAdding] = useState(false);
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: ["schedules"] });
|
|
const noDest = (destinations.data?.length ?? 0) === 0;
|
|
|
|
return (
|
|
<section>
|
|
<SectionTitle icon={<CalendarClock className="h-4 w-4" />}>Scheduled backups</SectionTitle>
|
|
<div className="space-y-3">
|
|
{isLoading ? (
|
|
<Spinner />
|
|
) : (
|
|
data?.map((s) => <ScheduleRow key={s.id} schedule={s} onChange={invalidate} />)
|
|
)}
|
|
{data?.length === 0 && !adding && (
|
|
<Card>
|
|
<p className="text-sm text-slate-500">
|
|
No scheduled backups. Add one to automatically push a stack to a destination
|
|
on a recurring schedule.
|
|
</p>
|
|
</Card>
|
|
)}
|
|
{adding ? (
|
|
<ScheduleForm
|
|
stacks={stacks.data ?? []}
|
|
destinations={destinations.data ?? []}
|
|
agents={agents.data ?? []}
|
|
onDone={() => { setAdding(false); invalidate(); }}
|
|
onCancel={() => setAdding(false)}
|
|
/>
|
|
) : (
|
|
<Button variant="outline" onClick={() => setAdding(true)} disabled={noDest}>
|
|
<Plus className="h-4 w-4" /> Add schedule
|
|
</Button>
|
|
)}
|
|
{noDest && (
|
|
<p className="text-xs text-slate-500">Add a backup destination first.</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChange: () => void }) {
|
|
const toggle = useMutation({
|
|
mutationFn: () => schedulesApi.update(schedule.id, { enabled: !schedule.enabled }),
|
|
onSuccess: onChange,
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
const run = useMutation({
|
|
mutationFn: () => schedulesApi.run(schedule.id),
|
|
onSuccess: (r) =>
|
|
r.ok ? toast.success(`Backed up to ${r.destination}`) : toast.error(r.error || "Backup failed"),
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
const remove = useMutation({
|
|
mutationFn: () => schedulesApi.remove(schedule.id),
|
|
onSuccess: () => { toast.success("Schedule removed"); onChange(); },
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
|
|
const ok = schedule.last_status === "ok";
|
|
|
|
return (
|
|
<Card className="space-y-2">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
{schedule.agent_name && <Badge>{schedule.agent_name}</Badge>}
|
|
<span className="font-mono text-sm font-medium">{schedule.stack_id}</span>
|
|
<span className="text-slate-400">→</span>
|
|
<Badge>{schedule.destination_name ?? `dest ${schedule.destination_id}`}</Badge>
|
|
{!schedule.enabled && <span className="text-xs text-slate-400">disabled</span>}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" onClick={() => run.mutate()} loading={run.isPending}>
|
|
<Play className="h-4 w-4" /> Run now
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => toggle.mutate()} loading={toggle.isPending}>
|
|
<Power className="h-4 w-4" /> {schedule.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>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
|
<span>{describeSchedule(schedule)}</span>
|
|
<span>keep {schedule.keep || "∞"}</span>
|
|
{schedule.include_volumes && <span>+volumes</span>}
|
|
{schedule.next_run && <span>next {relativeTime(schedule.next_run)}</span>}
|
|
{schedule.last_run && (
|
|
<span className={ok ? "text-green-600 dark:text-green-400" : "text-red-500"}>
|
|
last {relativeTime(schedule.last_run)} · {schedule.last_status}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function ScheduleForm({
|
|
stacks,
|
|
destinations,
|
|
agents,
|
|
onDone,
|
|
onCancel,
|
|
}: {
|
|
stacks: { id: string; name: string }[];
|
|
destinations: BackupDestination[];
|
|
agents: Agent[];
|
|
onDone: () => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const [host, setHost] = useState("local"); // "local" | agent id (string)
|
|
const [form, setForm] = useState({
|
|
stack_id: stacks[0]?.id ?? "",
|
|
destination_id: destinations[0]?.id ?? 0,
|
|
frequency: "daily",
|
|
hour: 3,
|
|
minute: 0,
|
|
weekday: 0,
|
|
include_volumes: true,
|
|
stop_first: true,
|
|
keep: 7,
|
|
enabled: true,
|
|
});
|
|
const set = (k: string, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
|
|
|
|
const isRemote = host !== "local";
|
|
const agentId = isRemote ? Number(host) : undefined;
|
|
|
|
// When a remote host is selected, pull its stacks for the picker.
|
|
const remoteStacks = useQuery({
|
|
queryKey: ["agent-stacks", agentId],
|
|
queryFn: () => agentsApi.stacks(agentId!),
|
|
enabled: isRemote,
|
|
});
|
|
const stackOptions = isRemote
|
|
? (remoteStacks.data ?? []).map((s) => ({ id: s.id, name: s.name }))
|
|
: stacks;
|
|
|
|
// Keep stack_id valid as host/options change.
|
|
useEffect(() => {
|
|
if (stackOptions.length && !stackOptions.some((s) => s.id === form.stack_id)) {
|
|
set("stack_id", stackOptions[0].id);
|
|
}
|
|
}, [stackOptions]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const onlineAgents = agents.filter((a) => a.status === "online");
|
|
|
|
const create = useMutation({
|
|
mutationFn: () => schedulesApi.create({ ...form, agent_id: agentId ?? null }),
|
|
onSuccess: () => { toast.success("Schedule added"); onDone(); },
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
|
|
return (
|
|
<Card className="space-y-3">
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Host</span>
|
|
<select className={selectClass} value={host} onChange={(e) => setHost(e.target.value)}>
|
|
<option value="local">This host</option>
|
|
{onlineAgents.map((a) => (
|
|
<option key={a.id} value={String(a.id)}>{a.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Stack</span>
|
|
<select className={selectClass} value={form.stack_id} onChange={(e) => set("stack_id", e.target.value)}>
|
|
{stackOptions.length === 0 && <option value="">{isRemote ? "no stacks" : "—"}</option>}
|
|
{stackOptions.map((s) => (
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Destination</span>
|
|
<select className={selectClass} value={form.destination_id} onChange={(e) => set("destination_id", Number(e.target.value))}>
|
|
{destinations.map((d) => (
|
|
<option key={d.id} value={d.id}>{d.name} ({d.type})</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Frequency</span>
|
|
<select className={selectClass} value={form.frequency} onChange={(e) => set("frequency", e.target.value)}>
|
|
<option value="hourly">Hourly</option>
|
|
<option value="daily">Daily</option>
|
|
<option value="weekly">Weekly</option>
|
|
</select>
|
|
</label>
|
|
{form.frequency === "weekly" && (
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Weekday</span>
|
|
<select className={selectClass} value={form.weekday} onChange={(e) => set("weekday", Number(e.target.value))}>
|
|
{WEEKDAYS.map((d, i) => (
|
|
<option key={d} value={i}>{d}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
{form.frequency !== "hourly" && (
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Hour (UTC)</span>
|
|
<Input type="number" min={0} max={23} value={form.hour} onChange={(e) => set("hour", Number(e.target.value))} />
|
|
</label>
|
|
)}
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Minute</span>
|
|
<Input type="number" min={0} max={59} value={form.minute} onChange={(e) => set("minute", Number(e.target.value))} />
|
|
</label>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Keep (retention)</span>
|
|
<Input type="number" min={0} value={form.keep} onChange={(e) => set("keep", Number(e.target.value))} />
|
|
</label>
|
|
<label className="flex items-end gap-2 pb-2 text-sm">
|
|
<input type="checkbox" checked={form.include_volumes} onChange={(e) => set("include_volumes", e.target.checked)} className="h-4 w-4" />
|
|
Include volumes
|
|
</label>
|
|
<label className="flex items-end gap-2 pb-2 text-sm">
|
|
<input type="checkbox" checked={form.stop_first} onChange={(e) => set("stop_first", e.target.checked)} className="h-4 w-4" />
|
|
Stop during backup
|
|
</label>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
|
<Button onClick={() => create.mutate()} loading={create.isPending} disabled={!form.stack_id || !form.destination_id}>
|
|
Add schedule
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Backup destinations */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
const selectClass =
|
|
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800";
|
|
|
|
const FIELDS: Record<string, { key: string; label: string; secret?: boolean; area?: boolean; placeholder?: string }[]> = {
|
|
sftp: [
|
|
{ key: "host", label: "Host" },
|
|
{ key: "port", label: "Port", placeholder: "22" },
|
|
{ key: "username", label: "Username" },
|
|
{ key: "password", label: "Password", secret: true },
|
|
{ key: "private_key", label: "Private key (optional, instead of password)", secret: true, area: true },
|
|
{ key: "path", label: "Remote directory", placeholder: "/backups/stackpilot" },
|
|
],
|
|
s3: [
|
|
{ key: "endpoint_url", label: "Endpoint URL (blank = AWS)", placeholder: "https://minio.example:9000" },
|
|
{ key: "region", label: "Region", placeholder: "us-east-1" },
|
|
{ key: "bucket", label: "Bucket" },
|
|
{ key: "access_key", label: "Access key", secret: true },
|
|
{ key: "secret_key", label: "Secret key", secret: true },
|
|
{ key: "prefix", label: "Key prefix (optional)", placeholder: "stackpilot/" },
|
|
],
|
|
};
|
|
|
|
function DestinationsSection() {
|
|
const qc = useQueryClient();
|
|
const { data, isLoading } = useQuery({ queryKey: ["destinations"], queryFn: destinationsApi.list });
|
|
const [adding, setAdding] = useState(false);
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: ["destinations"] });
|
|
|
|
return (
|
|
<section>
|
|
<SectionTitle icon={<HardDrive className="h-4 w-4" />}>Backup destinations</SectionTitle>
|
|
<div className="space-y-3">
|
|
{isLoading ? (
|
|
<Spinner />
|
|
) : (
|
|
data?.map((d) => <DestinationRow key={d.id} dest={d} onChange={invalidate} />)
|
|
)}
|
|
{data?.length === 0 && !adding && (
|
|
<Card>
|
|
<p className="text-sm text-slate-500">
|
|
No destinations. Add an SFTP server or S3-compatible bucket to push stack
|
|
backups off-box and restore from them.
|
|
</p>
|
|
</Card>
|
|
)}
|
|
{adding ? (
|
|
<DestinationForm onDone={() => { setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} />
|
|
) : (
|
|
<Button variant="outline" onClick={() => setAdding(true)}>
|
|
<Plus className="h-4 w-4" /> Add destination
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function DestinationRow({ dest, onChange }: { dest: BackupDestination; onChange: () => void }) {
|
|
const test = useMutation({
|
|
mutationFn: () => destinationsApi.test(dest.id),
|
|
onSuccess: (r) =>
|
|
r.ok ? toast.success("Reachable") : toast.error(r.error || "Connection failed"),
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
const remove = useMutation({
|
|
mutationFn: () => destinationsApi.remove(dest.id),
|
|
onSuccess: () => { toast.success("Destination removed"); onChange(); },
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
|
|
const summary =
|
|
dest.type === "s3"
|
|
? `${dest.config.bucket}${dest.config.prefix ? "/" + dest.config.prefix : ""}`
|
|
: `${dest.config.username}@${dest.config.host}:${dest.config.path || "."}`;
|
|
|
|
return (
|
|
<Card className="flex flex-wrap items-center justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{dest.name}</span>
|
|
<Badge>{dest.type}</Badge>
|
|
</div>
|
|
<p className="break-all font-mono text-xs text-slate-500">{summary}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" onClick={() => test.mutate()} loading={test.isPending}>
|
|
<RefreshCw className="h-4 w-4" /> Test
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
|
|
<Trash2 className="h-4 w-4 text-red-500" />
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
|
|
const [name, setName] = useState("");
|
|
const [type, setType] = useState("sftp");
|
|
const [config, setConfig] = useState<Record<string, string>>({});
|
|
|
|
const create = useMutation({
|
|
mutationFn: () => destinationsApi.create({ name, type, config }),
|
|
onSuccess: () => { toast.success("Destination added"); onDone(); },
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
|
|
const setField = (k: string, v: string) => setConfig((c) => ({ ...c, [k]: v }));
|
|
|
|
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={name} onChange={(e) => setName(e.target.value)} placeholder="offsite-nas" />
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Type</span>
|
|
<select className={selectClass} value={type} onChange={(e) => { setType(e.target.value); setConfig({}); }}>
|
|
<option value="sftp">SFTP</option>
|
|
<option value="s3">S3-compatible</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
{FIELDS[type].map((f) => (
|
|
<label key={f.key} className="block space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">{f.label}</span>
|
|
{f.area ? (
|
|
<textarea
|
|
value={config[f.key] ?? ""}
|
|
onChange={(e) => setField(f.key, e.target.value)}
|
|
rows={3}
|
|
className={selectClass + " font-mono"}
|
|
/>
|
|
) : (
|
|
<Input
|
|
type={f.secret ? "password" : "text"}
|
|
placeholder={f.placeholder}
|
|
value={config[f.key] ?? ""}
|
|
onChange={(e) => setField(f.key, e.target.value)}
|
|
/>
|
|
)}
|
|
</label>
|
|
))}
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
|
<Button onClick={() => create.mutate()} loading={create.isPending} disabled={!name.trim()}>
|
|
Add destination
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Remote hosts (agents) */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
function HostsSection() {
|
|
const qc = useQueryClient();
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ["agents"],
|
|
queryFn: () => agentsApi.list(),
|
|
refetchInterval: 15000,
|
|
});
|
|
const [adding, setAdding] = useState(false);
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: ["agents"] });
|
|
|
|
return (
|
|
<section>
|
|
<SectionTitle icon={<Server className="h-4 w-4" />}>Remote hosts</SectionTitle>
|
|
<div className="space-y-3">
|
|
{isLoading ? (
|
|
<Spinner />
|
|
) : (
|
|
data?.map((a) => <HostRow key={a.id} agent={a} onChange={invalidate} />)
|
|
)}
|
|
{data?.length === 0 && !adding && (
|
|
<Card>
|
|
<p className="text-sm text-slate-500">
|
|
No remote hosts. Deploy <code>stackpilot-agent</code> on another host and
|
|
add it here to manage its stacks from this dashboard.
|
|
</p>
|
|
</Card>
|
|
)}
|
|
{adding ? (
|
|
<AddHostForm onDone={() => { setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} />
|
|
) : (
|
|
<Button variant="outline" onClick={() => setAdding(true)}>
|
|
<Plus className="h-4 w-4" /> Add host
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function HostRow({ agent, onChange }: { agent: Agent; onChange: () => void }) {
|
|
const ping = useMutation({
|
|
mutationFn: () => agentsApi.ping(agent.id),
|
|
onSuccess: (r) => {
|
|
toast[r.status === "online" ? "success" : "error"](`Host is ${r.status}`);
|
|
onChange();
|
|
},
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
const remove = useMutation({
|
|
mutationFn: () => agentsApi.remove(agent.id),
|
|
onSuccess: () => { toast.success("Host removed"); onChange(); },
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
|
|
return (
|
|
<Card className="flex flex-wrap items-center justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<HostDot status={agent.status} />
|
|
<span className="font-medium">{agent.name}</span>
|
|
<span className="text-xs text-slate-400">{agent.status}</span>
|
|
{agent.hostname && (
|
|
<span className="font-mono text-xs text-slate-400">({agent.hostname})</span>
|
|
)}
|
|
</div>
|
|
<p className="break-all font-mono text-xs text-slate-500">{agent.url}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" onClick={() => ping.mutate()} loading={ping.isPending}>
|
|
<RefreshCw className="h-4 w-4" /> Check
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
|
|
<Trash2 className="h-4 w-4 text-red-500" />
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function AddHostForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
|
|
const [name, setName] = useState("");
|
|
const [url, setUrl] = useState("");
|
|
const [token, setToken] = useState("");
|
|
|
|
const create = useMutation({
|
|
mutationFn: () => agentsApi.create({ name, url, token }),
|
|
onSuccess: (a) => {
|
|
toast[a.status === "online" ? "success" : "error"](
|
|
a.status === "online" ? "Host added and reachable" : `Host added but ${a.status}`
|
|
);
|
|
onDone();
|
|
},
|
|
onError: (e) => toast.error(apiErrorMessage(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={name} onChange={(e) => setName(e.target.value)} placeholder="nas" />
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Agent URL</span>
|
|
<Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="http://10.0.0.5:5010" />
|
|
</label>
|
|
</div>
|
|
<label className="block space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Shared token (AGENT_TOKEN)</span>
|
|
<Input type="password" value={token} onChange={(e) => setToken(e.target.value)} />
|
|
</label>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
|
<Button
|
|
onClick={() => create.mutate()}
|
|
loading={create.isPending}
|
|
disabled={!name.trim() || !url.trim() || !token}
|
|
>
|
|
Add host
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* 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>
|
|
);
|
|
}
|