Files
manage/frontend/src/pages/Settings.tsx
T
2026-07-14 16:07:04 +00:00

1689 lines
48 KiB
TypeScript

import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type {
MonitoringMachine,
MonitoringMachineInput,
SSHKey,
SSHKeyInput,
} from "../types";
import {
useDeleteMonitoringMachine,
useDeleteSSHKey,
useGenerateSSHKey,
useMonitoringSettings,
useResetLocalDatabase,
useSSHKeys,
useSaveMonitoringMachine,
useSaveSSHKey,
useTestMonitoringMachineSSH,
} from "../hooks/useSettings";
import { useIsMobile } from "../hooks/useIsMobile";
import { CreateServiceDialog, DashboardManagementCard } from "./ServicesPage";
import { SheetForm } from "@/components/ui/sheet-form";
import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton";
import { SectionCard } from "../components/SectionCard";
import { SelectionRailCard } from "../components/SelectionRailCard";
import { TabbedCard } from "../components/TabbedCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { cn } from "@/lib/utils";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
useDeleteServiceInstance,
useSaveServiceInstance,
useServiceInstances,
useServiceTypes,
useTestServiceInstance,
} from "../hooks/useServices";
import { ServiceTestPanel } from "../components/ServiceTestPanel";
import type {
ServiceInstance,
ServiceInstanceInput,
ServiceTestResult,
ServiceTypeInfo,
} from "../types";
const SERVICE_OPTIONS = [
{ value: "monitoring", label: "Monitoring" },
{ value: "files", label: "Files" },
{ value: "nextcloud", label: "Nextcloud" },
];
// Radix Select disallows empty-string item values, so the "no selection" option
// maps to this sentinel and converts back to "" at the draft boundary.
const NONE = "__none__";
type SettingsTab =
| "machines"
| "ssh-keys"
| "services"
| "dashboards"
| "danger";
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
function FormField({
label,
htmlFor,
helperText,
children,
}: {
label: string;
htmlFor?: string;
helperText?: string;
children: ReactNode;
}) {
return (
<div className="flex flex-col">
<Label htmlFor={htmlFor} className="mb-1">
{label}
</Label>
{children}
{helperText ? (
<p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
) : null}
</div>
);
}
/** Overline section label (replaces MUI `Typography variant="overline"`). */
function SectionLabel({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="pt-1">
<p className="text-[0.7rem] leading-tight font-medium tracking-wide text-muted-foreground uppercase">
{title}
</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
);
}
function emptyMachine(
mode: MonitoringMachineInput["mode"] = "local",
): MonitoringMachineInput {
return {
id: null,
name: mode === "local" ? "This machine" : "",
mode,
enabled: true,
services: mode === "local" ? ["monitoring", "files"] : [],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key: "",
ssh_private_key_passphrase: "",
password: "",
notes: "",
};
}
/**
* Dirty check for the machine editor SheetForm guard (spec R4.5).
* Pragmatic field-by-field comparison of the user-editable fields. In create
* mode (editingMachine is null) the form is always dirty.
*/
function isMachineDraftDirty(
draft: MonitoringMachineInput,
editingMachine: MonitoringMachine | null,
): boolean {
if (!editingMachine) return true;
return (
draft.name !== editingMachine.name ||
draft.host !== editingMachine.host ||
draft.mode !== editingMachine.mode ||
draft.port !== editingMachine.port ||
draft.username !== editingMachine.username ||
draft.ssh_key_id !== editingMachine.ssh_key_id ||
draft.enabled !== editingMachine.enabled ||
draft.notes !== editingMachine.notes ||
JSON.stringify([...draft.services].sort()) !==
JSON.stringify([...editingMachine.services].sort())
);
}
function MachineEditor({
title,
hint,
machine,
sshKeys,
editingMachine,
onChange,
onValidateSSH,
isValidatingSSH,
sshValidationMessage,
sshValidationError,
sshValidationStatus,
}: {
title: string;
hint?: string;
machine: MonitoringMachineInput;
sshKeys: SSHKey[];
editingMachine?: MonitoringMachine | null;
onChange: (
draft:
| MonitoringMachineInput
| ((current: MonitoringMachineInput) => MonitoringMachineInput),
) => void;
onValidateSSH: () => void;
isValidatingSSH: boolean;
sshValidationMessage: string;
sshValidationError: string;
sshValidationStatus: string;
}) {
const draft = machine;
const setDraft = onChange;
const isLocal = draft.mode === "local";
const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id);
const enabledServices = draft.services.length;
const placeholderIfSet = (isSet: boolean | undefined) =>
isSet ? "Set, not shown" : undefined;
return (
<Card>
<CardContent className="flex flex-col gap-4 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold">{title}</p>
{hint ? (
<p className="text-xs text-muted-foreground">{hint}</p>
) : null}
</div>
<div className="flex flex-row flex-wrap items-center gap-1">
<Badge variant="outline">{draft.mode}</Badge>
<Badge variant="outline">
{draft.enabled ? "enabled" : "disabled"}
</Badge>
<Badge variant="outline">{`${enabledServices} services`}</Badge>
</div>
</div>
<div className="grid grid-cols-12 gap-2">
<div className="col-span-12">
<SectionLabel
title="General"
description="Name, mode, enabled state, and service roles."
/>
</div>
<div className="col-span-12 md:col-span-6">
<FormField label="Name" htmlFor="machine-name">
<Input
id="machine-name"
value={draft.name}
onChange={(e) =>
setDraft((current) => ({ ...current, name: e.target.value }))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-3">
{editingMachine ? (
<FormField label="Mode">
<Input value={draft.mode} disabled />
</FormField>
) : (
<FormField label="Mode">
<Select
value={draft.mode}
onValueChange={(value) => {
const newMode = value as MonitoringMachineInput["mode"];
setDraft((current) => ({
...emptyMachine(newMode),
id: current.id,
}));
}}
>
<SelectTrigger className="w-full" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="ssh">SSH</SelectItem>
</SelectContent>
</Select>
</FormField>
)}
</div>
<div className="col-span-12 md:col-span-3">
<div className="flex items-center gap-2">
<Switch
id="machine-enabled"
className="mobile-touch-target"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft((current) => ({ ...current, enabled: checked }))
}
/>
<Label htmlFor="machine-enabled">
{draft.enabled ? "Enabled" : "Disabled"}
</Label>
</div>
</div>
<div className="col-span-12">
<div className="flex flex-row flex-wrap items-center gap-1">
{SERVICE_OPTIONS.map((option) => {
const checked = draft.services.includes(option.value);
return (
<button
key={option.value}
type="button"
onClick={() =>
setDraft((current) => ({
...current,
services: checked
? current.services.filter(
(service) => service !== option.value,
)
: [...current.services, option.value],
}))
}
className={cn(
"inline-flex h-5 cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-colors",
checked
? "bg-primary text-primary-foreground"
: "border border-border text-foreground hover:bg-muted",
)}
>
{option.label}
</button>
);
})}
</div>
</div>
{!isLocal && (
<>
<div className="col-span-12">
<SectionLabel
title="Connection"
description="SSH host, port, username, and credentials."
/>
</div>
<div className="col-span-12 md:col-span-4">
<FormField label="Host">
<Input
value={draft.host}
onChange={(e) =>
setDraft((current) => ({
...current,
host: e.target.value,
}))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-2">
<FormField label="Port">
<Input
type="number"
value={draft.port}
onChange={(e) =>
setDraft((current) => ({
...current,
port: Number(e.target.value || 22),
}))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-3">
<FormField label="Username">
<Input
value={draft.username}
onChange={(e) =>
setDraft((current) => ({
...current,
username: e.target.value,
}))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-4">
<FormField
label="SSH key"
helperText={
sshKeys.length > 0
? "Select a saved SSH key."
: "No SSH keys are saved yet. Add one in the SSH Keys tab."
}
>
<Select
value={draft.ssh_key_id || NONE}
onValueChange={(value) =>
setDraft((current) => ({
...current,
ssh_key_id: value === NONE ? "" : value,
}))
}
>
<SelectTrigger className="w-full" size="sm">
<SelectValue placeholder="No key selected" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>No key selected</SelectItem>
{sshKeys.map((key) => (
<SelectItem key={key.id} value={key.id}>
{key.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
<div className="col-span-12 md:col-span-6">
<FormField label="Password">
<Input
type="password"
placeholder={placeholderIfSet(editingMachine?.password_set)}
value={draft.password}
onChange={(e) =>
setDraft((current) => ({
...current,
password: e.target.value,
}))
}
/>
</FormField>
</div>
</>
)}
<div className="col-span-12">
<SectionLabel
title="Monitoring / Files"
description="Enable monitoring and file browsing for this machine."
/>
</div>
{isLocal && (
<div className="col-span-12 md:col-span-6">
<FormField label="Local hint">
<Input value="Uses the API host directly" disabled />
</FormField>
</div>
)}
<div className="col-span-12">
<SectionLabel
title="Notes"
description="Free-form administrator notes for this machine."
/>
</div>
<div className="col-span-12">
<FormField label="Notes">
<Input
value={draft.notes}
onChange={(e) =>
setDraft((current) => ({ ...current, notes: e.target.value }))
}
/>
</FormField>
</div>
</div>
{selectedSSHKey ? (
<Alert>
<AlertDescription>
Selected key: {selectedSSHKey.name}
{selectedSSHKey.fingerprint
? ` · ${selectedSSHKey.fingerprint}`
: ""}
</AlertDescription>
</Alert>
) : !isLocal && sshKeys.length === 0 ? (
<Alert>
<AlertDescription>
No SSH keys have been saved yet. Add one before configuring SSH
machines.
</AlertDescription>
</Alert>
) : draft.ssh_key_id ? (
<Alert variant="destructive">
<AlertDescription>
The selected SSH key was not found.
</AlertDescription>
</Alert>
) : null}
{!isLocal && enabledServices === 0 && (
<Alert>
<AlertDescription>
SSH machines usually need monitoring or files enabled.
</AlertDescription>
</Alert>
)}
{!isLocal && (
<div className="flex flex-col gap-2">
<Alert>
<AlertDescription>
Validate SSH before saving: this records the first trusted host
key in the backend-managed known_hosts file, then checks SSH
auth.
</AlertDescription>
</Alert>
<div className="flex flex-row flex-wrap items-center gap-2">
<Button
className="mobile-touch-target"
variant="outline"
onClick={onValidateSSH}
disabled={
isValidatingSSH ||
!draft.host.trim() ||
!draft.username.trim()
}
>
{isValidatingSSH
? "Validating SSH..."
: "Validate SSH + trust host"}
</Button>
<p className="text-xs text-muted-foreground">
SSH status: {sshValidationStatus || "Not tested yet"}
</p>
</div>
{sshValidationMessage && (
<Alert>
<AlertDescription>{sshValidationMessage}</AlertDescription>
</Alert>
)}
{sshValidationError && (
<Alert variant="destructive">
<AlertDescription>{sshValidationError}</AlertDescription>
</Alert>
)}
</div>
)}
</CardContent>
</Card>
);
}
function SSHKeyManager({
sshKeys,
selectedKeyId,
onSelectKeyId,
}: {
sshKeys: SSHKey[];
selectedKeyId: string;
onSelectKeyId: (id: string) => void;
}) {
const saveKey = useSaveSSHKey();
const generateKey = useGenerateSSHKey();
const deleteKey = useDeleteSSHKey();
const [draft, setDraft] = useState<SSHKeyInput>({
id: null,
name: "",
private_key: "",
public_key: "",
fingerprint: "",
passphrase: "",
notes: "",
});
const selectedKey = useMemo(
() => sshKeys.find((key) => key.id === selectedKeyId) ?? null,
[sshKeys, selectedKeyId],
);
const editing = Boolean(draft.id);
const clear = () => {
setDraft({
id: null,
name: "",
private_key: "",
passphrase: "",
public_key: "",
fingerprint: "",
notes: "",
});
onSelectKeyId("");
};
const loadKey = (key: SSHKey) => {
onSelectKeyId(key.id);
setDraft({
id: key.id,
name: key.name,
private_key: "",
passphrase: "",
public_key: key.public_key,
fingerprint: key.fingerprint,
notes: key.notes,
});
};
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
<SelectionRailCard
title="SSH keys"
description="Select a key to see its details."
minHeight={420}
footer={
<Button
variant="outline"
size="sm"
className="mobile-touch-target w-full"
onClick={() => {
clear();
}}
>
New key
</Button>
}
>
{sshKeys.length === 0 && (
<div className="p-4 text-center">
<p className="text-xs text-muted-foreground">
No SSH keys saved yet.
</p>
</div>
)}
{sshKeys.map((key) => {
const active = key.id === selectedKeyId;
return (
<div
key={key.id}
onClick={() => loadKey(key)}
className={cn(
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5 group-hover:[&_.rail-edit]:opacity-100",
active ? "bg-muted" : "bg-card hover:bg-muted/50",
)}
>
<div className="min-w-0">
<p className="truncate font-semibold">{key.name}</p>
<p className="text-xs text-muted-foreground">
{key.private_key_set ? "key saved" : "no key"} ·{" "}
{key.usage_count} machine
{key.usage_count === 1 ? "" : "s"}
</p>
</div>
<HoverEditButton onClick={() => loadKey(key)} />
</div>
);
})}
</SelectionRailCard>
<SectionCard
title={selectedKey?.name || "No key selected"}
description={
selectedKey
? `${selectedKey.fingerprint || "No fingerprint"}`
: "Select a key on the left or create a new one."
}
action={
selectedKey ? (
<Badge variant="outline">{`${selectedKey.usage_count} machine${selectedKey.usage_count === 1 ? "" : "s"}`}</Badge>
) : undefined
}
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-12 gap-2">
<div className="col-span-12 md:col-span-4">
<FormField label="Key name">
<Input
value={draft.name}
onChange={(e) =>
setDraft((current) => ({
...current,
name: e.target.value,
}))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-4">
<FormField label="Passphrase" helperText="Optional">
<Input
type="password"
value={draft.passphrase}
onChange={(e) =>
setDraft((current) => ({
...current,
passphrase: e.target.value,
}))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-4">
<FormField label="Notes">
<Input
value={draft.notes}
onChange={(e) =>
setDraft((current) => ({
...current,
notes: e.target.value,
}))
}
/>
</FormField>
</div>
<div className="col-span-12">
<FormField
label="Private key"
helperText={
editing
? "Leave blank to keep the existing private key."
: "Paste the full key text here."
}
>
<Textarea
rows={6}
value={draft.private_key}
onChange={(e) =>
setDraft((current) => ({
...current,
private_key: e.target.value,
}))
}
/>
</FormField>
</div>
</div>
<div className="flex flex-row flex-wrap items-center gap-2">
<Button
className="mobile-touch-target"
disabled={saveKey.isPending}
onClick={async () => {
await saveKey.mutateAsync(draft);
clear();
}}
>
{editing ? "Update key" : "Save key"}
</Button>
<Button
className="mobile-touch-target"
variant="outline"
disabled={generateKey.isPending}
onClick={async () => {
const generated = await generateKey.mutateAsync({
name:
draft.name ||
`ssh-key-${new Date().toISOString().slice(0, 10)}`,
passphrase: draft.passphrase,
notes: draft.notes,
});
setDraft((current) => ({
...current,
name: generated.name || current.name,
private_key: generated.private_key,
passphrase: generated.passphrase,
public_key: generated.public_key,
fingerprint: generated.fingerprint,
notes: generated.notes,
}));
}}
>
{generateKey.isPending ? "Generating..." : "Generate key"}
</Button>
<Button
variant="outline"
onClick={clear}
className="mobile-touch-target"
>
Clear
</Button>
{selectedKey && (
<Button
className="mobile-touch-target"
variant="destructive"
onClick={() => deleteKey.mutate(selectedKey.id)}
>
Delete
</Button>
)}
</div>
{saveKey.error && (
<Alert variant="destructive">
<AlertDescription>{String(saveKey.error)}</AlertDescription>
</Alert>
)}
{selectedKey && (
<div className="flex flex-col gap-2">
<div className="rounded-lg border border-border px-3 py-2">
<p className="text-xs text-muted-foreground">Fingerprint</p>
<p className="break-all font-mono text-sm">
{selectedKey.fingerprint || "Unavailable"}
</p>
</div>
<div className="rounded-lg border border-border px-3 py-2">
<p className="text-xs text-muted-foreground">Public key</p>
<p className="break-all font-mono text-sm">
{selectedKey.public_key || "Unavailable"}
</p>
</div>
</div>
)}
</div>
</SectionCard>
</div>
);
}
function ResetLocalDatabaseCard() {
const resetDatabase = useResetLocalDatabase();
const [open, setOpen] = useState(false);
const [phrase, setPhrase] = useState("");
const [ackSettings, setAckSettings] = useState(false);
const [ackIndex, setAckIndex] = useState(false);
const [ackIrreversible, setAckIrreversible] = useState(false);
const canSubmit =
phrase.trim().toLowerCase() === "reset local database" &&
ackSettings &&
ackIndex &&
ackIrreversible;
const close = () => {
setOpen(false);
setPhrase("");
setAckSettings(false);
setAckIndex(false);
setAckIrreversible(false);
};
return (
<>
<Card>
<CardContent className="flex flex-col gap-4 p-3">
<div className="flex flex-row flex-wrap items-center gap-2">
<p className="text-sm font-semibold">Danger zone</p>
<Badge variant="destructive">Destructive</Badge>
</div>
<p className="text-xs text-muted-foreground">
Reset the local SQLite settings/media index databases after
acknowledging the data loss.
</p>
<Button
variant="destructive"
onClick={() => setOpen(true)}
className="mobile-touch-target"
>
Reset local database
</Button>
{resetDatabase.error && (
<Alert variant="destructive">
<AlertDescription>{String(resetDatabase.error)}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) close();
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reset local database</DialogTitle>
<DialogDescription>
This deletes the local settings and media index SQLite files. It
does not affect remote servers.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<label className="flex items-center gap-2 text-sm">
<Checkbox
className="mobile-touch-target"
checked={ackSettings}
onCheckedChange={(checked) => setAckSettings(Boolean(checked))}
/>
I understand settings will be lost.
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
className="mobile-touch-target"
checked={ackIndex}
onCheckedChange={(checked) => setAckIndex(Boolean(checked))}
/>
I understand the media index will be rebuilt.
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
className="mobile-touch-target"
checked={ackIrreversible}
onCheckedChange={(checked) =>
setAckIrreversible(Boolean(checked))
}
/>
I understand this cannot be undone.
</label>
<FormField label='Type "reset local database" to confirm'>
<Input
value={phrase}
onChange={(e) => setPhrase(e.target.value)}
/>
</FormField>
</div>
<DialogFooter
onCancel={close}
cancelLabel="Cancel"
onConfirm={async () => {
await resetDatabase.mutateAsync({
confirm_phrase: phrase,
acknowledge_settings_loss: ackSettings,
acknowledge_media_index_loss: ackIndex,
acknowledge_irreversible: ackIrreversible,
});
close();
}}
confirmLabel="Reset database"
confirmBusyLabel="Resetting..."
confirmColor="error"
confirmDisabled={!canSubmit || resetDatabase.isPending}
/>
</DialogContent>
</Dialog>
</>
);
}
export function Settings() {
const { data: machines, error } = useMonitoringSettings();
const { data: sshKeys = [] } = useSSHKeys();
const saveMachine = useSaveMonitoringMachine();
const deleteMachine = useDeleteMonitoringMachine();
const testMachineSSH = useTestMonitoringMachineSSH();
const [searchParams] = useSearchParams();
const [tab, setTab] = useState<SettingsTab>(
(searchParams.get("tab") as SettingsTab | null) ?? "machines",
);
const initialServiceId = searchParams.get("service") ?? "";
const [deleteMachineId, setDeleteMachineId] = useState<string | null>(null);
const [selectedSSHKeyId, setSelectedSSHKeyId] = useState("");
const [sshValidationMessage, setSSHValidationMessage] = useState("");
const [sshValidationError, setSSHValidationError] = useState("");
const [sshValidationStatus, setSSHValidationStatus] = useState("");
const [machineDialogOpen, setMachineDialogOpen] = useState(false);
const [machineDraft, setMachineDraft] = useState<MonitoringMachineInput>(
emptyMachine(),
);
const [editingMachine, setEditingMachine] =
useState<MonitoringMachine | null>(null);
const [selectedMachineId, setSelectedMachineId] = useState("");
const isMobile = useIsMobile();
const orderedMachines = useMemo(() => machines ?? [], [machines]);
const selectedMachine = useMemo(
() =>
orderedMachines.find((machine) => machine.id === selectedMachineId) ??
orderedMachines[0] ??
null,
[orderedMachines, selectedMachineId],
);
const clearSSHValidation = () => {
setSSHValidationMessage("");
setSSHValidationError("");
setSSHValidationStatus("");
};
const openEditMachine = (
input: MonitoringMachineInput,
original?: MonitoringMachine | null,
) => {
clearSSHValidation();
setMachineDraft(input);
setEditingMachine(original ?? null);
setMachineDialogOpen(true);
};
const closeMachineDialog = () => {
clearSSHValidation();
setMachineDialogOpen(false);
setEditingMachine(null);
};
const updateMachineDraft = (
draft:
| MonitoringMachineInput
| ((current: MonitoringMachineInput) => MonitoringMachineInput),
) => {
clearSSHValidation();
setMachineDraft(draft);
};
const saveMachineDraft = async (draft: MonitoringMachineInput) => {
clearSSHValidation();
await saveMachine.mutateAsync(draft);
setMachineDialogOpen(false);
setEditingMachine(null);
setMachineDraft(emptyMachine(draft.mode));
};
const validateMachineSSH = async () => {
clearSSHValidation();
try {
const result = await testMachineSSH.mutateAsync(machineDraft);
setSSHValidationMessage(result.message);
setSSHValidationStatus(
result.known_hosts_updated
? "Host key trusted and SSH auth succeeded."
: "Host key already trusted and SSH auth succeeded.",
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setSSHValidationError(message);
const lowered = message.toLowerCase();
if (lowered.includes("protocol banner")) {
setSSHValidationStatus("SSH banner not received.");
} else if (
lowered.includes("no authentication methods available") ||
lowered.includes("authentication failed")
) {
setSSHValidationStatus("SSH authentication failed.");
} else {
setSSHValidationStatus("SSH validation failed.");
}
}
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold">Settings</h1>
<p className="text-xs text-muted-foreground">
Structure machines, reusable SSH keys, and safety controls from a
tabbed admin workspace.
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{String(error)}</AlertDescription>
</Alert>
)}
{saveMachine.error && (
<Alert variant="destructive">
<AlertDescription>{String(saveMachine.error)}</AlertDescription>
</Alert>
)}
{deleteMachine.error && (
<Alert variant="destructive">
<AlertDescription>{String(deleteMachine.error)}</AlertDescription>
</Alert>
)}
<TabbedCard
value={tab}
onChange={(value) => setTab(value as SettingsTab)}
tabs={[
<TabsTrigger key="machines" value="machines">
Machines
</TabsTrigger>,
<TabsTrigger key="ssh-keys" value="ssh-keys">
SSH Keys
</TabsTrigger>,
<TabsTrigger key="services" value="services">
Services
</TabsTrigger>,
<TabsTrigger key="dashboards" value="dashboards">
Dashboards
</TabsTrigger>,
<TabsTrigger key="danger" value="danger">
Danger Zone
</TabsTrigger>,
]}
contentSx={{}}
>
{tab === "machines" && (
<div className="flex flex-col gap-4">
{orderedMachines.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
<SelectionRailCard
title="Machines"
description="Select a machine to see its settings."
minHeight={420}
footer={
<Button
variant="outline"
size="sm"
className="mobile-touch-target w-full"
onClick={() => {
clearSSHValidation();
setMachineDraft(emptyMachine("local"));
setEditingMachine(null);
setMachineDialogOpen(true);
}}
>
Add machine
</Button>
}
>
{orderedMachines.map((machine) => {
const active = machine.id === selectedMachine?.id;
return (
<div
key={machine.id}
onClick={() => setSelectedMachineId(machine.id)}
className={cn(
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5 group-hover:[&_.rail-edit]:opacity-100",
active ? "bg-muted" : "bg-card hover:bg-muted/50",
)}
>
<div className="min-w-0">
<p className="truncate font-semibold">
{machine.name}
</p>
<p className="text-xs text-muted-foreground">
{machine.mode} ·{" "}
{machine.enabled ? "Enabled" : "Disabled"}
</p>
</div>
<HoverEditButton
onClick={() => {
openEditMachine(
{
id: machine.id,
name: machine.name,
mode: machine.mode,
enabled: machine.enabled,
services: machine.services,
host: machine.host,
port: machine.port,
username: machine.username,
key_directory: "",
key_name: "",
ssh_key_id: machine.ssh_key_id,
ssh_private_key: "",
ssh_private_key_passphrase: "",
password: "",
notes: machine.notes,
},
machine,
);
}}
/>
</div>
);
})}
</SelectionRailCard>
<SectionCard
title={selectedMachine?.name || "No machine selected"}
description={
selectedMachine
? selectedMachine.mode === "local"
? "Local API host"
: `${selectedMachine.username || "user"}@${selectedMachine.host || "host"}:${selectedMachine.port}`
: "Select a machine on the left to view its settings."
}
action={
selectedMachine ? (
<Badge variant="outline">{selectedMachine.mode}</Badge>
) : undefined
}
>
{selectedMachine ? (
<div className="flex flex-col gap-4">
<div className="flex flex-row flex-wrap items-center gap-1">
<Badge variant="outline">
{selectedMachine.enabled ? "enabled" : "disabled"}
</Badge>
<Badge variant="outline">{`${selectedMachine.services.length} services`}</Badge>
{selectedMachine.ssh_key_id && (
<Badge variant="outline">{`SSH key ${selectedMachine.ssh_key_id}`}</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{selectedMachine.notes || "No notes."}
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{selectedMachine.mode === "ssh" ? (
<>
<FormField label="Host">
<Input value={selectedMachine.host} disabled />
</FormField>
<FormField label="Port">
<Input value={selectedMachine.port} disabled />
</FormField>
<FormField label="Username">
<Input
value={selectedMachine.username}
disabled
/>
</FormField>
</>
) : (
<FormField label="Local hint">
<Input
value="Uses the API host directly"
disabled
/>
</FormField>
)}
</div>
<div className="flex flex-row flex-wrap items-center gap-2">
<Button
className="mobile-touch-target"
variant="outline"
onClick={() =>
openEditMachine(
{
id: selectedMachine.id,
name: selectedMachine.name,
mode: selectedMachine.mode,
enabled: selectedMachine.enabled,
services: selectedMachine.services,
host: selectedMachine.host,
port: selectedMachine.port,
username: selectedMachine.username,
key_directory: "",
key_name: "",
ssh_key_id: selectedMachine.ssh_key_id,
ssh_private_key: "",
ssh_private_key_passphrase: "",
password: "",
notes: selectedMachine.notes,
},
selectedMachine,
)
}
>
Edit
</Button>
<Button
className="mobile-touch-target"
variant="destructive"
onClick={() => setDeleteMachineId(selectedMachine.id)}
>
Delete
</Button>
</div>
</div>
) : null}
</SectionCard>
</div>
) : null}
</div>
)}
{tab === "ssh-keys" && (
<SSHKeyManager
sshKeys={sshKeys}
selectedKeyId={selectedSSHKeyId}
onSelectKeyId={setSelectedSSHKeyId}
/>
)}
{tab === "services" && (
<ServicesAdminCard initialServiceId={initialServiceId} />
)}
{tab === "dashboards" && <DashboardManagementCard />}
{tab === "danger" && <ResetLocalDatabaseCard />}
</TabbedCard>
{isMobile ? (
<SheetForm
open={machineDialogOpen}
onOpenChange={(open) => {
if (!open) closeMachineDialog();
}}
title={machineDraft.id ? "Edit machine" : "Create machine"}
onSave={() => {
void saveMachineDraft(machineDraft);
}}
onCancel={closeMachineDialog}
isPending={saveMachine.isPending}
saveDisabled={
!machineDraft.name ||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
}
saveLabel={machineDraft.id ? "Save machine" : "Create machine"}
isDirty={isMachineDraftDirty(machineDraft, editingMachine)}
>
<MachineEditor
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
title={
machineDraft.id
? machineDraft.name || "Edit machine"
: "New machine"
}
hint={
machineDraft.mode === "local" ? "Local API host" : "SSH target"
}
machine={machineDraft}
sshKeys={sshKeys}
editingMachine={editingMachine}
onChange={updateMachineDraft}
onValidateSSH={validateMachineSSH}
isValidatingSSH={testMachineSSH.isPending}
sshValidationMessage={sshValidationMessage}
sshValidationError={sshValidationError}
sshValidationStatus={sshValidationStatus}
/>
{machineDraft.id ? (
<Button
className="mobile-touch-target"
variant="destructive"
onClick={() => setDeleteMachineId(machineDraft.id as string)}
>
Delete machine
</Button>
) : null}
</SheetForm>
) : (
<Dialog
open={machineDialogOpen}
onOpenChange={(open) => {
if (!open) closeMachineDialog();
}}
>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>
{machineDraft.id ? "Edit machine" : "Create machine"}
</DialogTitle>
<DialogDescription>
{machineDraft.mode === "local"
? "Local API host"
: "SSH target"}
</DialogDescription>
</DialogHeader>
<MachineEditor
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
title={
machineDraft.id
? machineDraft.name || "Edit machine"
: "New machine"
}
hint={
machineDraft.mode === "local" ? "Local API host" : "SSH target"
}
machine={machineDraft}
sshKeys={sshKeys}
editingMachine={editingMachine}
onChange={updateMachineDraft}
onValidateSSH={validateMachineSSH}
isValidatingSSH={testMachineSSH.isPending}
sshValidationMessage={sshValidationMessage}
sshValidationError={sshValidationError}
sshValidationStatus={sshValidationStatus}
/>
<DialogFooter
onCancel={closeMachineDialog}
cancelLabel="Cancel"
onConfirm={() => {
void saveMachineDraft(machineDraft);
}}
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
confirmDisabled={
!machineDraft.name ||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
}
secondaryAction={
machineDraft.id ? (
<Button
className="mobile-touch-target"
variant="destructive"
onClick={() => {
setDeleteMachineId(machineDraft.id as string);
}}
>
Delete
</Button>
) : undefined
}
/>
</DialogContent>
</Dialog>
)}
<ConfirmDialog
open={Boolean(deleteMachineId)}
title="Delete machine?"
message="The machine will be removed. Action history will be deleted."
onCancel={() => setDeleteMachineId(null)}
onConfirm={() => {
if (deleteMachineId) {
deleteMachine.mutate(deleteMachineId);
closeMachineDialog();
}
setDeleteMachineId(null);
}}
/>
</div>
);
}
/**
* Services admin card for the Settings > Services tab.
*
* Lists all service instances grouped by type with inline config editing
* (enable/disable, config fields, secrets, save, delete). Lifted from the
* old ServicePage ConfigBody — the service page is now a pure operational
* view; all administration lives here.
*/
function ServicesAdminCard({
initialServiceId = "",
}: {
initialServiceId?: string;
}) {
const { data: services = [] } = useServiceInstances();
const { data: types = [] } = useServiceTypes();
const [selectedServiceId, setSelectedServiceId] = useState(initialServiceId);
const [createOpen, setCreateOpen] = useState(false);
const sortedServices = useMemo(
() =>
[...services].sort((a, b) =>
`${a.service_type}:${a.name}`.localeCompare(
`${b.service_type}:${b.name}`,
),
),
[services],
);
const selectedService = useMemo(
() =>
sortedServices.find((s) => s.id === selectedServiceId) ??
sortedServices[0] ??
null,
[sortedServices, selectedServiceId],
);
const selectedTypeInfo = selectedService
? types.find((t) => t.service_type === selectedService.service_type)
: undefined;
return (
<>
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button variant="outline" onClick={() => setCreateOpen(true)}>
Add service
</Button>
</div>
{sortedServices.length === 0 ? (
<p className="text-sm text-muted-foreground">
No service instances configured yet.
</p>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
<SelectionRailCard
title="Services"
description="Select a service to edit its configuration."
minHeight={420}
>
{sortedServices.map((svc) => {
const active = svc.id === (selectedService?.id ?? "");
const typeName =
types.find((t) => t.service_type === svc.service_type)
?.name ?? svc.service_type;
return (
<div
key={svc.id}
onClick={() => setSelectedServiceId(svc.id)}
className={cn(
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5",
active ? "bg-muted" : "bg-card hover:bg-muted/50",
)}
>
<div className="min-w-0">
<p className="truncate font-semibold">{svc.name}</p>
<p className="text-xs text-muted-foreground">
{typeName} · {svc.enabled ? "Enabled" : "Disabled"}
</p>
</div>
<Badge variant={svc.enabled ? "default" : "secondary"}>
{svc.enabled ? "on" : "off"}
</Badge>
</div>
);
})}
</SelectionRailCard>
<SectionCard
title={selectedService?.name ?? "No service selected"}
description={
selectedTypeInfo?.description ??
"Select a service on the left to edit its configuration."
}
>
{selectedService ? (
<ServiceConfigEditor
// Remount on service switch so useState initializers (name, config,
// secrets) re-run for the new instance. Without this key, switching
// services in the rail keeps the previous service's editable state
// and a save writes stale name/config onto the new service's row.
key={selectedService.id}
instance={selectedService}
typeInfo={selectedTypeInfo}
/>
) : (
<p className="text-sm text-muted-foreground">
Select a service on the left.
</p>
)}
</SectionCard>
</div>
)}
</div>
<CreateServiceDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
/>
</>
);
}
function ServiceConfigEditor({
instance,
typeInfo,
}: {
instance: ServiceInstance;
typeInfo: ServiceTypeInfo | undefined;
}) {
const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance();
const testService = useTestServiceInstance();
const [name, setName] = useState(instance.name);
const [enabled, setEnabled] = useState(instance.enabled);
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({
...instance.config,
});
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
const [deleteOpen, setDeleteOpen] = useState(false);
const [testResult, setTestResult] = useState<ServiceTestResult | null>(null);
const [saveAnyway, setSaveAnyway] = useState(false);
// Build a test input from the current editor state. For the test, include
// ALL typed secrets (unfiltered) so the backend can authenticate.
function buildTestInput(): ServiceInstanceInput {
return {
id: instance.id,
service_type: instance.service_type,
name,
config: draftConfig,
secrets: draftSecrets,
enabled,
};
}
const testInput = buildTestInput();
// Reset test state when the test input changes (store-previous pattern).
const [prevTestInput, setPrevTestInput] = useState(testInput);
if (
testInput !== prevTestInput &&
JSON.stringify(testInput) !== JSON.stringify(prevTestInput)
) {
setPrevTestInput(testInput);
setTestResult(null);
setSaveAnyway(false);
}
const testPassed = (testResult?.ok ?? false) || saveAnyway;
async function handleTest() {
try {
const res = await testService.mutateAsync(testInput);
setTestResult(res);
} catch (err) {
setTestResult({
ok: false,
detail: err instanceof Error ? err.message : String(err),
evidence: null,
});
}
}
const properties =
(
(typeInfo?.config_schema ?? {}) as {
properties?: Record<string, { type?: string; description?: string }>;
}
).properties ?? {};
const configEntries: Array<
[string, { type?: string; description?: string }]
> =
Object.keys(properties).length > 0
? Object.entries(properties).map(([key, schema]) => [
key,
{ type: schema?.type, description: schema?.description },
])
: Object.entries(instance.config).map(([key, value]) => [
key,
{ type: typeof value === "number" ? "integer" : "string" },
]);
function buildInput(): ServiceInstanceInput {
const onlyChangedSecrets = Object.fromEntries(
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
);
return {
id: instance.id,
service_type: instance.service_type,
name,
config: draftConfig,
secrets: onlyChangedSecrets,
enabled,
};
}
async function handleSave() {
await saveService.mutateAsync(buildInput());
setDraftSecrets({});
}
return (
<>
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<span className="font-medium">{instance.name}</span>
<Badge variant={enabled ? "default" : "secondary"}>
{enabled ? "enabled" : "disabled"}
</Badge>
</div>
<div className="flex flex-col gap-3">
<FormField label="Name" htmlFor={`svc-name-${instance.id}`}>
<Input
id={`svc-name-${instance.id}`}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</FormField>
<div className="flex items-center gap-2">
<Switch
id={`svc-enabled-${instance.id}`}
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor={`svc-enabled-${instance.id}`}>Enabled</Label>
</div>
{configEntries.map(([key, schema]) => {
const isNumber =
schema.type === "integer" || schema.type === "number";
return (
<FormField
key={key}
label={key}
htmlFor={`svc-cfg-${instance.id}-${key}`}
helperText={schema.description}
>
<Input
id={`svc-cfg-${instance.id}-${key}`}
type={isNumber ? "number" : "text"}
value={String(draftConfig[key] ?? "")}
onChange={(e) =>
setDraftConfig({
...draftConfig,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
</FormField>
);
})}
{Object.keys(instance.secrets_set).length === 0
? null
: Object.entries(instance.secrets_set).map(([key, isSet]) => (
<FormField
key={key}
label={key}
htmlFor={`svc-secret-${instance.id}-${key}`}
helperText="Leave blank to keep the current value."
>
<Input
id={`svc-secret-${instance.id}-${key}`}
type="password"
placeholder={isSet ? "•••••• (set)" : "Not set"}
value={draftSecrets[key] ?? ""}
onChange={(e) =>
setDraftSecrets({
...draftSecrets,
[key]: e.target.value,
})
}
/>
</FormField>
))}
<ServiceTestPanel
result={testResult}
isPending={testService.isPending}
saveAnyway={saveAnyway}
onTest={handleTest}
onSaveAnywayChange={setSaveAnyway}
/>
<div className="flex justify-between">
<Button
onClick={handleSave}
disabled={saveService.isPending || !testPassed}
className="mobile-touch-target"
>
Save
</Button>
<Button
variant="destructive"
onClick={() => setDeleteOpen(true)}
className="mobile-touch-target"
>
Delete
</Button>
</div>
</div>
</div>
<ConfirmDialog
open={deleteOpen}
title="Delete service?"
message="This removes the service and any widgets that reference it. This cannot be undone."
confirmLabel="Delete"
onCancel={() => setDeleteOpen(false)}
onConfirm={() => {
deleteService.mutate(instance.id);
setDeleteOpen(false);
}}
/>
</>
);
}