e805c624b2
Below md, the machine editor Dialog renders as a SheetForm (triggered by the same Edit/Add buttons via machineDialogOpen state). The shared MachineEditor body (fields + SSH validate button) renders inside the sheet; the ConfirmDialog is a sibling outside. Desktop Dialog is byte-for-byte identical. No navigation needed on close -- the Settings page content (tabbed cards, machine list) is always visible behind the sheet, so there is no stranding risk (unlike ServicePage where the sheet was the whole page). Added saveDisabled prop to SheetForm (additive, default false) so the machine editor can gate Save on required fields (name + host for SSH mode), matching the desktop DialogFooter confirmDisabled semantics. Scope note: SSHKeyManager is an inline two-panel layout (SelectionRailCard + SectionCard), not a dialog, and already stacks responsively via grid-cols-1 md:grid-cols-[...]. Wrapping it in SheetForm would break its always-visible selection rail. Left as-is. Tests: 3 new mobile cases (SheetForm render, save payload, cancel closes) + desktop unchanged. 113 tests pass; lint/build green. Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 7).
1269 lines
36 KiB
TypeScript
1269 lines
36 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";
|
|
|
|
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 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="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>
|
|
{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"}
|
|
>
|
|
<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
|
|
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
|
|
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>
|
|
);
|
|
}
|