8bc209b27e
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.
1215 lines
35 KiB
TypeScript
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>
|
|
);
|
|
}
|