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