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) => (