Files
manage/frontend/src/pages/Settings.tsx
T
Developer 8bc209b27e docs: fix stale-live docs and drop obsolete Obsidian spec
Refreshes the docs that were actively misleading about the current
FastAPI + React + service-registry app, and deletes one obsolete design.

- CONTRIBUTING.md: full rewrite — Streamlit-era guidance replaced with
  the current backend (ruff/pytest, src/ layout) + frontend (npm
  lint/build/test) workflow, service-registry model, and shadcn/Tailwind
  stack. Mirrors AGENTS.md.
- README.md: removed the non-existent /addons/:addonId route (Services
  page is current); fixed the per-machine Jellyfin wording; replaced the
  py_compile dev snippet with ruff + pytest / npm lint+build+test.
- backend/README.md: updated the structure tree (removed deleted
  clients/resources.py; added routers backups/services/tasks/widgets,
  integrations/, models/, widgets/, workers/); dropped the "starts the
  collector" sentence (MonitoringPoller is decommissioned).
- frontend/README.md: corrected the uvicorn module path
  (main:app -> media_library_viewer_api.main:app).
- Deleted docs/superpowers/specs/2026-05-08-obsidian-documentation-design.md
  (Obsidian vault never built; stack refs MUI/D3/AG Grid all removed).

Historical docs (MIGRATION_PLAN, superpowers backup-monitoring, the
bannered design/runbook/context files) deferred to a later banner pass.
2026-06-25 09:07:19 +00:00

1215 lines
35 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 { 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";
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" | "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: "",
};
}
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"
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
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="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
disabled={saveKey.isPending}
onClick={async () => {
await saveKey.mutateAsync(draft);
clear();
}}
>
{editing ? "Update key" : "Save key"}
</Button>
<Button
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}>
Clear
</Button>
{selectedKey && (
<Button
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)}>
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
checked={ackSettings}
onCheckedChange={(checked) => setAckSettings(Boolean(checked))}
/>
I understand settings will be lost.
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
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
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 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="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="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
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
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 === "danger" && <ResetLocalDatabaseCard />}
</TabbedCard>
<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
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>
);
}