Files
manage/frontend/src/pages/Settings.tsx
T
Developer 5a43894875 Fix: sheet scroll, direct-edit close, mobile copy btn, service badge
Four fixes:

1. Mobile edit fullscreen scroll: the Sheet primitive's
   data-[side=bottom]:h-auto was overriding our h-[100dvh] on
   SheetForm, preventing scroll. Added data-[side=bottom]:h-[100dvh]
   to the SheetForm className to win the specificity battle.

2. Direct-edit close showed list view: when opened via editWidgetId
   (the hover edit button), saving or canceling called reset() which
   showed the widget list instead of closing the dialog. Now derives
   directEdit from editWidgetId — when true, reset() calls onClose()
   to close entirely.

3. Copy button missing on mobile: MobileWidgetSections didn't pass
   onCopy to its WidgetInstanceCard instances. Now accepts and wires
   onCopyWidget, so referenced widgets show the copy/detach button on
   mobile too.

4. Service enabled badge stale: ServiceConfigEditor showed
   instance.enabled (the initial prop) instead of the local enabled
   state. Now reads the local enabled variable so the badge updates
   when the user toggles the switch.

128 tests pass; 0 lint errors; build clean.
2026-07-06 14:51:14 +00:00

1596 lines
45 KiB
TypeScript

import type { ReactNode } from "react";
import { useMemo, useState } from "react";
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 { 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,
} from "../hooks/useServices";
import type {
ServiceInstance,
ServiceInstanceInput,
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" | "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 [tab, setTab] = useState<SettingsTab>("machines");
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="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 />}
{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() {
const { data: services = [] } = useServiceInstances();
const { data: types = [] } = useServiceTypes();
const [selectedServiceId, setSelectedServiceId] = useState("");
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;
if (sortedServices.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No service instances configured. Create one from the Services page.
</p>
);
}
return (
<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
instance={selectedService}
typeInfo={selectedTypeInfo}
/>
) : (
<p className="text-sm text-muted-foreground">
Select a service on the left.
</p>
)}
</SectionCard>
</div>
);
}
function ServiceConfigEditor({
instance,
typeInfo,
}: {
instance: ServiceInstance;
typeInfo: ServiceTypeInfo | undefined;
}) {
const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance();
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 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>
))}
<div className="flex justify-between">
<Button
onClick={handleSave}
disabled={saveService.isPending}
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);
}}
/>
</>
);
}