Files
stackpilot/frontend/src/pages/Settings.tsx
T
menzeljandClaude Opus 4.8 7bd449101d Phase 6: remote backup destinations — SFTP & S3 (0.6.0)
- BackupDestination model + backup_destination_service (SFTP via paramiko,
  S3-compatible via boto3): upload/list/download/delete/test.
- routers/destinations.py: destinations CRUD (secrets masked, merge-on-update),
  test, list/delete remote backups. backups.py: POST /{id}/backup/push and
  POST /restore-from (download from a destination + restore, volumes included).
- Frontend: Settings → Backup destinations (SFTP/S3 forms + test); Backup dialog
  can push to a destination; Restore dialog can pick a destination + backup.
- deps: paramiko 3.5.0, boto3 1.35.99.

Verified end-to-end against live MinIO + atmoz/sftp: create/test destinations,
push (incl. volumes), list, restore-from to a fresh stack (volume data intact),
delete remote backup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:50:03 +00:00

754 lines
26 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,
} 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 { agentsApi } from "@/api/agents";
import { HostDot } from "@/components/hosts/HostDot";
import { apiErrorMessage } from "@/api/client";
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 />
<NotificationsSection />
<UsersSection />
</div>
);
}
/* -------------------------------------------------------------------------- */
/* 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>
);
}