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 (
Admin only
Settings are available to administrators only.
);
}
return (
);
}
/* -------------------------------------------------------------------------- */
/* 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 = {
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 (
}>Backup destinations
{isLoading ? (
) : (
data?.map((d) =>
)
)}
{data?.length === 0 && !adding && (
No destinations. Add an SFTP server or S3-compatible bucket to push stack
backups off-box and restore from them.
)}
{adding ? (
{ setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} />
) : (
)}
);
}
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 (
{dest.name}
{dest.type}
{summary}
);
}
function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
const [name, setName] = useState("");
const [type, setType] = useState("sftp");
const [config, setConfig] = useState>({});
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 (
{FIELDS[type].map((f) => (
))}
);
}
/* -------------------------------------------------------------------------- */
/* 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 (
}>Remote hosts
{isLoading ? (
) : (
data?.map((a) =>
)
)}
{data?.length === 0 && !adding && (
No remote hosts. Deploy stackpilot-agent on another host and
add it here to manage its stacks from this dashboard.
)}
{adding ? (
{ setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} />
) : (
)}
);
}
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 (
{agent.name}
{agent.status}
{agent.hostname && (
({agent.hostname})
)}
{agent.url}
);
}
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 (
);
}
/* -------------------------------------------------------------------------- */
/* General */
/* -------------------------------------------------------------------------- */
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
{icon}
{children}
);
}
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 (
);
}
/* -------------------------------------------------------------------------- */
/* Notifications */
/* -------------------------------------------------------------------------- */
const EVENT_LABELS: Record = {
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 (
}>Notifications
{webhooks.isLoading ? (
) : (
webhooks.data?.map((w) =>
)
)}
{webhooks.data?.length === 0 && !adding && (
No webhooks yet. Add ntfy, Discord, Slack, Gotify, or a generic JSON endpoint.
)}
{adding && settings.data && (
{
setAdding(false);
qc.invalidateQueries({ queryKey: ["webhooks"] });
}}
onCancel={() => setAdding(false)}
/>
)}
{!adding && (
)}
);
}
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 (
{webhook.name}
{webhook.type}
{!webhook.enabled && disabled}
{webhook.url}
{webhook.events.map((e) => (
{EVENT_LABELS[e] ?? e}
))}
);
}
function WebhookForm({
types,
events,
onDone,
onCancel,
}: {
types: string[];
events: string[];
onDone: () => void;
onCancel: () => void;
}) {
const [form, setForm] = useState({
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 (
Events
{events.map((e) => (
))}
);
}
/* -------------------------------------------------------------------------- */
/* 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 (
}>Users
{isLoading ? (
) : (
)}
{adding ? (
{ setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} />
) : (
)}
);
}
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 (
{user.username}
{user.role}
{!user.is_active && inactive}
{isSelf && you}
{!isSelf && (
)}
);
}
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 (
);
}