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 (
Admin only
Settings are available to administrators only.
);
}
return (
);
}
/* -------------------------------------------------------------------------- */
/* 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 (
}>Scheduled backups
{isLoading ? (
) : (
data?.map((s) =>
)
)}
{data?.length === 0 && !adding && (
No scheduled backups. Add one to automatically push a stack to a destination
on a recurring schedule.
)}
{adding ? (
{ setAdding(false); invalidate(); }}
onCancel={() => setAdding(false)}
/>
) : (
)}
{noDest && (
Add a backup destination first.
)}
);
}
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 (
{schedule.agent_name && {schedule.agent_name}}
{schedule.stack_id}
→
{schedule.destination_name ?? `dest ${schedule.destination_id}`}
{!schedule.enabled && disabled}
{describeSchedule(schedule)}
keep {schedule.keep || "∞"}
{schedule.include_volumes && +volumes}
{schedule.next_run && next {relativeTime(schedule.next_run)}}
{schedule.last_run && (
last {relativeTime(schedule.last_run)} · {schedule.last_status}
)}
);
}
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 (
{form.frequency === "weekly" && (
)}
{form.frequency !== "hourly" && (
)}
);
}
/* -------------------------------------------------------------------------- */
/* 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 (
);
}