feat(frontend): slice 5 — migrate Settings + Actions to shadcn/Tailwind

Web UI rework. Form-heavy pair (controlled useState parity, no form lib):
- pages/Settings.tsx off @mui: monitoring-machine CRUD, SSH-key mgmt,
  SSH test/validation feedback, danger-zone reset (ConfirmDialog), tabs
- pages/Actions.tsx off @mui: saved-task editor, machine selection,
  run history, tabs
- Both reuse migrated shared components (SectionCard/SelectionRailCard/
  TabbedCard/HoverEditButton/ConfirmDialog/DialogFooter) as before
- Behavioral tests added (mocked hooks; no live SSH)

Gate: build + lint + test green (19 files / 39 tests).
This commit is contained in:
Developer
2026-06-17 13:47:57 +00:00
parent c721f0dece
commit b6da7df7f9
6 changed files with 1490 additions and 1349 deletions
+338 -433
View File
@@ -1,26 +1,6 @@
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
Dialog,
DialogContent,
DialogTitle,
Divider,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
import type { MonitoringMachine, SavedTaskInput } from "../types";
import type { MonitoringMachine, SavedTask, SavedTaskInput } from "../types";
import {
useDeleteTask,
useMonitoringSettings,
@@ -31,10 +11,63 @@ import {
} from "../hooks/useSettings";
import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton";
import { SectionCard } from "../components/SectionCard";
import { SelectionRailCard } from "../components/SelectionRailCard";
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 {
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 { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
// Radix Select disallows empty-string item values; the "None" option maps to
// this sentinel and converts back to "" at the draft boundary.
const NONE = "__none__";
type ActionTab = "new" | string;
/** 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>
);
}
function emptyTask(): SavedTaskInput {
return {
id: null,
@@ -59,6 +92,18 @@ function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
);
}
function initialFromTask(task: SavedTask): SavedTaskInput {
return {
id: task.id,
name: task.name,
task_type: task.task_type,
content: task.content,
enabled: task.enabled,
default_machine_id: task.default_machine_id,
notes: task.notes,
};
}
function TaskEditor({
task,
machines,
@@ -72,101 +117,98 @@ function TaskEditor({
(machine) => machine.id === task.default_machine_id,
);
return (
<Stack spacing={1.5}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
<div className="flex flex-col gap-4">
<div className="flex flex-row flex-wrap items-center gap-2">
<p className="text-sm font-semibold">
{task.id ? "Edit action" : "New action"}
</Typography>
<Chip size="small" variant="outlined" label={task.task_type} />
<Chip
size="small"
variant="outlined"
label={task.enabled ? "enabled" : "disabled"}
/>
</p>
<Badge variant="outline">{task.task_type}</Badge>
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
{selectedMachine && (
<Chip
size="small"
variant="outlined"
label={`default: ${selectedMachine.name}`}
/>
<Badge variant="outline">{`default: ${selectedMachine.name}`}</Badge>
)}
</Stack>
</div>
<Stack spacing={1.25}>
<TextField
fullWidth
size="small"
label="Name"
value={task.name}
onChange={(e) => onChange({ ...task, name: e.target.value })}
/>
<Stack direction="row" spacing={1.25} sx={{ flexWrap: "wrap" }}>
<FormControl size="small" sx={{ minWidth: 180, flex: "1 1 180px" }}>
<InputLabel>Type</InputLabel>
<Select
label="Type"
value={task.task_type}
onChange={(e) =>
onChange({
...task,
task_type: e.target.value as SavedTaskInput["task_type"],
})
}
>
<MenuItem value="shell">Shell</MenuItem>
<MenuItem value="python">Python</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 220, flex: "1 1 220px" }}>
<InputLabel>Default machine</InputLabel>
<Select
label="Default machine"
value={task.default_machine_id}
onChange={(e) =>
onChange({
...task,
default_machine_id: String(e.target.value),
})
}
>
<MenuItem value="">None</MenuItem>
{machines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<TextField
fullWidth
size="small"
label="Notes"
value={task.notes}
onChange={(e) => onChange({ ...task, notes: e.target.value })}
/>
<TextField
fullWidth
multiline
minRows={9}
size="small"
<div className="flex flex-col gap-2">
<FormField label="Name" htmlFor="task-name">
<Input
id="task-name"
value={task.name}
onChange={(e) => onChange({ ...task, name: e.target.value })}
/>
</FormField>
<div className="flex flex-row flex-wrap gap-2">
<div className="min-w-[180px] flex-1">
<FormField label="Type">
<Select
value={task.task_type}
onValueChange={(value) =>
onChange({
...task,
task_type: value as SavedTaskInput["task_type"],
})
}
>
<SelectTrigger className="w-full" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="shell">Shell</SelectItem>
<SelectItem value="python">Python</SelectItem>
</SelectContent>
</Select>
</FormField>
</div>
<div className="min-w-[220px] flex-1">
<FormField label="Default machine">
<Select
value={task.default_machine_id || NONE}
onValueChange={(value) =>
onChange({
...task,
default_machine_id: value === NONE ? "" : value,
})
}
>
<SelectTrigger className="w-full" size="sm">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{machines.map((machine) => (
<SelectItem key={machine.id} value={machine.id}>
{machine.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
</div>
<FormField label="Notes">
<Input
value={task.notes}
onChange={(e) => onChange({ ...task, notes: e.target.value })}
/>
</FormField>
<FormField
label={
task.task_type === "python" ? "Python script" : "Shell command"
}
value={task.content}
onChange={(e) => onChange({ ...task, content: e.target.value })}
helperText={
task.task_type === "python"
? "Python is run as `python3 -c`."
: "Shell commands are run through `/bin/sh -c`."
}
/>
</Stack>
</Stack>
>
<Textarea
rows={9}
value={task.content}
onChange={(e) => onChange({ ...task, content: e.target.value })}
/>
</FormField>
</div>
</div>
);
}
@@ -200,25 +242,36 @@ function TaskDialog({
};
return (
<Dialog open={open} onClose={requestClose} fullWidth maxWidth="md">
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
<DialogContent dividers>
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) requestClose();
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
<DialogDescription>
Save a reusable server task. Shell commands run via{" "}
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
</DialogDescription>
</DialogHeader>
<TaskEditor task={task} machines={machines} onChange={onChange} />
<DialogFooter
onCancel={requestClose}
cancelLabel="Cancel"
onConfirm={onSave}
confirmLabel="Save action"
confirmBusyLabel="Save action"
secondaryAction={
onDelete ? (
<Button variant="destructive" onClick={onDelete}>
Delete
</Button>
) : undefined
}
/>
</DialogContent>
<DialogFooter
onCancel={requestClose}
cancelLabel="Cancel"
onConfirm={onSave}
confirmLabel="Save action"
confirmBusyLabel="Save action"
secondaryAction={
onDelete ? (
<Button variant="outlined" color="error" onClick={onDelete}>
Delete
</Button>
) : undefined
}
/>
</Dialog>
);
}
@@ -243,6 +296,12 @@ export function Actions() {
);
const selectedRuns = useTaskRuns(selectedTask?.id);
const openEdit = (initial: SavedTaskInput) => {
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
};
const createNew = () => {
const initial = emptyTask();
setDraft(initial);
@@ -271,51 +330,43 @@ export function Actions() {
const editingTask = selectedTask;
return (
<Stack spacing={2.25}>
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Actions
</Typography>
<Typography variant="body2" color="text.secondary">
<div className="flex flex-col gap-6">
<div className="flex flex-row flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-lg font-semibold">Actions</h1>
<p className="text-xs text-muted-foreground">
Save reusable server tasks and switch between them with tabs.
</Typography>
</Box>
<Chip label={`${tasks.length} saved`} variant="outlined" />
</Stack>
</p>
</div>
<Badge variant="outline">{`${tasks.length} saved`}</Badge>
</div>
{saveTask.error && (
<Alert severity="error">{String(saveTask.error)}</Alert>
<Alert variant="destructive">
<AlertDescription>{String(saveTask.error)}</AlertDescription>
</Alert>
)}
{deleteTask.error && (
<Alert severity="error">{String(deleteTask.error)}</Alert>
<Alert variant="destructive">
<AlertDescription>{String(deleteTask.error)}</AlertDescription>
</Alert>
)}
{runTask.error && (
<Alert variant="destructive">
<AlertDescription>{String(runTask.error)}</AlertDescription>
</Alert>
)}
{runTask.error && <Alert severity="error">{String(runTask.error)}</Alert>}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "280px minmax(0, 1fr)" },
gap: 2,
}}
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-[280px_minmax(0,1fr)]">
<SelectionRailCard
title="Saved actions"
description="Pick a saved task, then edit or run it from the detail pane."
contentSx={{ maxHeight: { xs: 520, md: 620 } }}
contentSx={{}}
footer={
<Button
variant="outlined"
size="small"
fullWidth
variant="outline"
size="sm"
className="w-full"
onClick={createNew}
>
Add action
@@ -324,301 +375,155 @@ export function Actions() {
>
<Tabs
value={tab}
onChange={(_, value) => setTab(value)}
onValueChange={(value) => setTab(value)}
orientation="vertical"
variant="scrollable"
sx={{ borderRight: 1, borderColor: "divider" }}
className="w-full"
>
{tasks.map((task) => (
<Box
key={task.id}
sx={{
position: "relative",
width: "100%",
"&:hover .rail-edit": { opacity: 1 },
}}
>
<Tab
value={task.id}
label={task.name}
sx={{
alignItems: "flex-start",
justifyContent: "flex-start",
width: 1,
pr: 5,
}}
onClick={() => setTab(task.id)}
onDoubleClick={() => {
const initial = {
id: task.id,
name: task.name,
task_type: task.task_type,
content: task.content,
enabled: task.enabled,
default_machine_id: task.default_machine_id,
notes: task.notes,
};
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
}}
/>
<Box
sx={{
position: "absolute",
right: 4,
top: "50%",
transform: "translateY(-50%)",
}}
<TabsList variant="line" className="h-fit w-full justify-start">
{tasks.map((task) => (
<div
key={task.id}
className="group relative w-full group-hover:[&_.rail-edit]:opacity-100"
>
<HoverEditButton
onClick={() => {
const initial = {
id: task.id,
name: task.name,
task_type: task.task_type,
content: task.content,
enabled: task.enabled,
default_machine_id: task.default_machine_id,
notes: task.notes,
};
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
}}
/>
</Box>
</Box>
))}
<TabsTrigger
value={task.id}
className="w-full justify-start pr-9"
onClick={() => setTab(task.id)}
onDoubleClick={() => openEdit(initialFromTask(task))}
>
{task.name}
</TabsTrigger>
<div className="absolute top-1/2 right-1 -translate-y-1/2">
<HoverEditButton
onClick={() => openEdit(initialFromTask(task))}
/>
</div>
</div>
))}
</TabsList>
</Tabs>
</SelectionRailCard>
<Stack spacing={2}>
<div className="flex flex-col gap-4">
{editingTask ? (
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1.5}>
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
<SectionCard
title={editingTask.name}
description="Open the editor popup to modify this action."
action={
<div className="flex flex-row flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={() => openEdit(initialFromTask(editingTask))}
>
Edit
</Button>
<Button
disabled={runTask.isPending || !runMachineId}
onClick={async () => {
await runTask.mutateAsync({
taskId: editingTask.id,
machineId: runMachineId,
});
}}
>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{editingTask.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Open the editor popup to modify this action.
</Typography>
</Box>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap" }}
>
<Button
variant="outlined"
onClick={() => {
const initial = {
id: editingTask.id,
name: editingTask.name,
task_type: editingTask.task_type,
content: editingTask.content,
enabled: editingTask.enabled,
default_machine_id: editingTask.default_machine_id,
notes: editingTask.notes,
};
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
}}
>
Edit
</Button>
<Button
variant="contained"
disabled={runTask.isPending || !runMachineId}
onClick={async () => {
await runTask.mutateAsync({
taskId: editingTask.id,
machineId: runMachineId,
});
}}
>
{runTask.isPending ? "Running..." : "Run action"}
</Button>
</Stack>
</Stack>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
{runTask.isPending ? "Running..." : "Run action"}
</Button>
</div>
}
>
<div className="flex flex-wrap items-center gap-2">
<FormField label="Run on machine">
<Select
value={runMachineId}
onValueChange={(value) => setRunMachineId(value)}
>
<FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel>Run on machine</InputLabel>
<Select
label="Run on machine"
value={runMachineId}
onChange={(e) =>
setRunMachineId(String(e.target.value))
}
>
{machines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Divider />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
Recent runs
</Typography>
{selectedRuns.data?.items?.length ? (
<Stack spacing={1.25}>
{selectedRuns.data.items.map((run) => (
<Card key={run.id} variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Chip
size="small"
variant="outlined"
label={run.status}
/>
<Typography
variant="body2"
color="text.secondary"
>
{run.machine_name} ·{" "}
{new Date(
run.created_at * 1000,
).toLocaleString()}
</Typography>
</Stack>
{run.stdout_tail && (
<Box
sx={{
px: 1,
py: 0.75,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Typography
variant="caption"
color="text.secondary"
>
stdout
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{run.stdout_tail}
</Typography>
</Box>
)}
{run.stderr_tail && (
<Box
sx={{
px: 1,
py: 0.75,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Typography
variant="caption"
color="text.secondary"
>
stderr
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{run.stderr_tail}
</Typography>
</Box>
)}
{run.error && (
<Alert severity="error">{run.error}</Alert>
)}
</Stack>
</CardContent>
</Card>
<SelectTrigger className="min-w-[240px]" size="sm">
<SelectValue placeholder="Select machine" />
</SelectTrigger>
<SelectContent>
{machines.map((machine) => (
<SelectItem key={machine.id} value={machine.id}>
{machine.name}
</SelectItem>
))}
</Stack>
) : (
<Alert severity="info">No runs yet.</Alert>
)}
</Stack>
</CardContent>
</Card>
</SelectContent>
</Select>
</FormField>
</div>
<Separator />
<p className="text-sm font-semibold">Recent runs</p>
{selectedRuns.data?.items?.length ? (
<div className="flex flex-col gap-2">
{selectedRuns.data.items.map((run) => (
<Card key={run.id}>
<CardContent className="flex flex-col gap-2 p-3">
<div className="flex flex-row flex-wrap items-center gap-2">
<Badge variant="outline">{run.status}</Badge>
<p className="text-xs text-muted-foreground">
{run.machine_name} ·{" "}
{new Date(run.created_at * 1000).toLocaleString()}
</p>
</div>
{run.stdout_tail && (
<div className="rounded-lg border border-border px-3 py-2">
<p className="text-xs text-muted-foreground">
stdout
</p>
<p className="whitespace-pre-wrap break-words font-mono text-sm">
{run.stdout_tail}
</p>
</div>
)}
{run.stderr_tail && (
<div className="rounded-lg border border-border px-3 py-2">
<p className="text-xs text-muted-foreground">
stderr
</p>
<p className="whitespace-pre-wrap break-words font-mono text-sm">
{run.stderr_tail}
</p>
</div>
)}
{run.error && (
<Alert variant="destructive">
<AlertDescription>{run.error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Alert>
<AlertDescription>No runs yet.</AlertDescription>
</Alert>
)}
</SectionCard>
) : (
<Stack spacing={2}>
<Card variant="outlined">
<CardContent sx={{ p: 2 }}>
<Stack spacing={1.25}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
No action selected
</Typography>
<Typography variant="body2" color="text.secondary">
Select a saved action from the list on the left to view
its details, run it, or open the editor popup. Use the
button at the bottom to add a new action.
</Typography>
<div className="flex flex-col gap-4">
<SectionCard
title="No action selected"
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup. Use the button at the bottom to add a new action."
>
{tasks[0] && (
<Button
variant="outlined"
onClick={() => setTab(tasks[0].id)}
>
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
Select first action
</Button>
)}
</Stack>
</CardContent>
</Card>
</SectionCard>
<Card variant="outlined">
<CardContent sx={{ p: 2 }}>
<Stack spacing={1}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
What this panel shows
</Typography>
<Typography variant="body2" color="text.secondary">
Saved actions stay on the left rail, while details, run
controls, and recent history appear here.
</Typography>
</Stack>
</CardContent>
</Card>
</Stack>
<SectionCard title="What this panel shows">
<p className="text-xs text-muted-foreground">
Saved actions stay on the left rail, while details, run
controls, and recent history appear here.
</p>
</SectionCard>
</div>
)}
</Stack>
</Box>
</div>
</div>
<TaskDialog
open={editOpen}
@@ -632,6 +537,6 @@ export function Actions() {
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
}
/>
</Stack>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Actions } from "../Actions";
import type { MonitoringMachine, SavedTask } from "../../types";
const saveTaskMutate = vi.fn().mockResolvedValue({
id: "t1",
name: "Restart svc",
task_type: "shell",
content: "",
enabled: true,
default_machine_id: "",
notes: "",
});
const deleteTaskMutate = vi.fn();
const runTaskMutate = vi.fn().mockResolvedValue({});
let machines: MonitoringMachine[] = [];
let tasks: SavedTask[] = [];
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: machines }),
useTasks: () => ({ data: tasks }),
useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }),
useDeleteTask: () => ({ mutate: deleteTaskMutate }),
useRunTask: () => ({ mutateAsync: runTaskMutate, isPending: false }),
useTaskRuns: () => ({ data: { items: [] } }),
}));
function machine(
overrides: Partial<MonitoringMachine> = {},
): MonitoringMachine {
return {
id: "m1",
name: "This machine",
mode: "local",
enabled: true,
services: ["monitoring", "files"],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
media_root: "",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "",
...overrides,
} as MonitoringMachine;
}
function task(overrides: Partial<SavedTask> = {}): SavedTask {
return {
id: "t1",
name: "Restart svc",
task_type: "shell",
content: "systemctl restart foo",
enabled: true,
default_machine_id: "",
notes: "",
created_at: 0,
updated_at: 0,
...overrides,
} as SavedTask;
}
beforeEach(() => {
saveTaskMutate.mockClear();
deleteTaskMutate.mockClear();
runTaskMutate.mockClear();
machines = [];
tasks = [];
});
describe("Actions", () => {
it("shows the empty state and creates a task via the editor dialog", async () => {
render(<Actions />);
expect(screen.getByText("No action selected")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add action" }),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Add action" }));
// Editor dialog opened (Name field is unique to the editor).
expect(screen.getByLabelText("Name")).toBeInTheDocument();
// Controlled input parity: name + default shell type flow through.
await userEvent.type(screen.getByLabelText("Name"), "Restart svc");
await userEvent.click(screen.getByRole("button", { name: "Save action" }));
expect(saveTaskMutate).toHaveBeenCalledTimes(1);
const saved = saveTaskMutate.mock.calls[0][0];
expect(saved.name).toBe("Restart svc");
expect(saved.task_type).toBe("shell");
});
it("disables the Run button until a run machine is selected", async () => {
machines = [machine()];
tasks = [task()];
render(<Actions />);
// Selecting a saved task tab exposes the detail + Run control.
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
const runButton = screen.getByRole("button", { name: "Run action" });
expect(runButton).toBeDisabled();
});
});
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Settings } from "../Settings";
import type { MonitoringMachine } from "../../types";
const saveMachineMutate = vi.fn().mockResolvedValue({});
const deleteMachineMutate = vi.fn();
const testSSHMutate = vi
.fn()
.mockResolvedValue({
message: "SSH auth succeeded",
known_hosts_updated: true,
});
let machines: MonitoringMachine[] = [];
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: machines }),
useSSHKeys: () => ({ data: [] }),
useSaveMonitoringMachine: () => ({
mutateAsync: saveMachineMutate,
isPending: false,
}),
useDeleteMonitoringMachine: () => ({ mutate: deleteMachineMutate }),
useTestMonitoringMachineSSH: () => ({
mutateAsync: testSSHMutate,
isPending: false,
}),
useResetLocalDatabase: () => ({}),
useSaveSSHKey: () => ({ mutateAsync: vi.fn() }),
useGenerateSSHKey: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteSSHKey: () => ({ mutate: vi.fn() }),
}));
function localMachine(
overrides: Partial<MonitoringMachine> = {},
): MonitoringMachine {
return {
id: "m1",
name: "This machine",
mode: "local",
enabled: true,
services: ["monitoring", "files", "jellyfin"],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
media_root: "/mnt/media",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "Primary node",
...overrides,
} as MonitoringMachine;
}
beforeEach(() => {
saveMachineMutate.mockClear();
deleteMachineMutate.mockClear();
testSSHMutate.mockClear();
machines = [];
});
describe("Settings", () => {
it("renders the machine list from the mocked store", () => {
machines = [localMachine()];
render(<Settings />);
// The rail row caption (mode · enabled) is unique to the selection rail.
expect(screen.getByText("local · Enabled")).toBeInTheDocument();
});
it("saves a machine via the editor dialog (controlled useState parity)", async () => {
machines = [localMachine()];
render(<Settings />);
// The detail-pane "Edit" has visible text "Edit"; the rail hover edit
// affordance is icon-only (aria-label "Edit") — disambiguate by text.
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
expect(screen.getByText("Edit machine")).toBeInTheDocument();
// Rename through the labeled field, then save.
const nameInput = screen.getByLabelText("Name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Worker node");
await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
expect(saveMachineMutate).toHaveBeenCalledTimes(1);
const saved = saveMachineMutate.mock.calls[0][0];
expect(saved.name).toBe("Worker node");
expect(saved.mode).toBe("local");
});
it("deletes a machine through the confirm dialog", async () => {
machines = [localMachine()];
render(<Settings />);
// Detail-pane "Delete" opens the confirm dialog.
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(screen.getByText("Delete machine?")).toBeInTheDocument();
// Confirm (the confirm dialog's "Delete" is the last one rendered).
const deletes = screen.getAllByRole("button", { name: "Delete" });
await userEvent.click(deletes[deletes.length - 1]);
expect(deleteMachineMutate).toHaveBeenCalledTimes(1);
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
});
});