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 { useMemo, useState } from "react";
import { import type { MonitoringMachine, SavedTask, SavedTaskInput } from "../types";
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 { import {
useDeleteTask, useDeleteTask,
useMonitoringSettings, useMonitoringSettings,
@@ -31,10 +11,63 @@ import {
} from "../hooks/useSettings"; } from "../hooks/useSettings";
import { DialogFooter } from "../components/DialogFooter"; import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton"; import { HoverEditButton } from "../components/HoverEditButton";
import { SectionCard } from "../components/SectionCard";
import { SelectionRailCard } from "../components/SelectionRailCard"; 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; 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 { function emptyTask(): SavedTaskInput {
return { return {
id: null, 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({ function TaskEditor({
task, task,
machines, machines,
@@ -72,101 +117,98 @@ function TaskEditor({
(machine) => machine.id === task.default_machine_id, (machine) => machine.id === task.default_machine_id,
); );
return ( return (
<Stack spacing={1.5}> <div className="flex flex-col gap-4">
<Stack <div className="flex flex-row flex-wrap items-center gap-2">
direction="row" <p className="text-sm font-semibold">
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{task.id ? "Edit action" : "New action"} {task.id ? "Edit action" : "New action"}
</Typography> </p>
<Chip size="small" variant="outlined" label={task.task_type} /> <Badge variant="outline">{task.task_type}</Badge>
<Chip <Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
size="small"
variant="outlined"
label={task.enabled ? "enabled" : "disabled"}
/>
{selectedMachine && ( {selectedMachine && (
<Chip <Badge variant="outline">{`default: ${selectedMachine.name}`}</Badge>
size="small"
variant="outlined"
label={`default: ${selectedMachine.name}`}
/>
)} )}
</Stack> </div>
<Stack spacing={1.25}> <div className="flex flex-col gap-2">
<TextField <FormField label="Name" htmlFor="task-name">
fullWidth <Input
size="small" id="task-name"
label="Name" value={task.name}
value={task.name} onChange={(e) => onChange({ ...task, name: e.target.value })}
onChange={(e) => onChange({ ...task, name: e.target.value })} />
/> </FormField>
<Stack direction="row" spacing={1.25} sx={{ flexWrap: "wrap" }}> <div className="flex flex-row flex-wrap gap-2">
<FormControl size="small" sx={{ minWidth: 180, flex: "1 1 180px" }}> <div className="min-w-[180px] flex-1">
<InputLabel>Type</InputLabel> <FormField label="Type">
<Select <Select
label="Type" value={task.task_type}
value={task.task_type} onValueChange={(value) =>
onChange={(e) => onChange({
onChange({ ...task,
...task, task_type: value as SavedTaskInput["task_type"],
task_type: e.target.value as SavedTaskInput["task_type"], })
}) }
} >
> <SelectTrigger className="w-full" size="sm">
<MenuItem value="shell">Shell</MenuItem> <SelectValue />
<MenuItem value="python">Python</MenuItem> </SelectTrigger>
</Select> <SelectContent>
</FormControl> <SelectItem value="shell">Shell</SelectItem>
<FormControl size="small" sx={{ minWidth: 220, flex: "1 1 220px" }}> <SelectItem value="python">Python</SelectItem>
<InputLabel>Default machine</InputLabel> </SelectContent>
<Select </Select>
label="Default machine" </FormField>
value={task.default_machine_id} </div>
onChange={(e) => <div className="min-w-[220px] flex-1">
onChange({ <FormField label="Default machine">
...task, <Select
default_machine_id: String(e.target.value), value={task.default_machine_id || NONE}
}) onValueChange={(value) =>
} onChange({
> ...task,
<MenuItem value="">None</MenuItem> default_machine_id: value === NONE ? "" : value,
{machines.map((machine) => ( })
<MenuItem key={machine.id} value={machine.id}> }
{machine.name} >
</MenuItem> <SelectTrigger className="w-full" size="sm">
))} <SelectValue placeholder="None" />
</Select> </SelectTrigger>
</FormControl> <SelectContent>
</Stack> <SelectItem value={NONE}>None</SelectItem>
<TextField {machines.map((machine) => (
fullWidth <SelectItem key={machine.id} value={machine.id}>
size="small" {machine.name}
label="Notes" </SelectItem>
value={task.notes} ))}
onChange={(e) => onChange({ ...task, notes: e.target.value })} </SelectContent>
/> </Select>
<TextField </FormField>
fullWidth </div>
multiline </div>
minRows={9} <FormField label="Notes">
size="small" <Input
value={task.notes}
onChange={(e) => onChange({ ...task, notes: e.target.value })}
/>
</FormField>
<FormField
label={ label={
task.task_type === "python" ? "Python script" : "Shell command" task.task_type === "python" ? "Python script" : "Shell command"
} }
value={task.content}
onChange={(e) => onChange({ ...task, content: e.target.value })}
helperText={ helperText={
task.task_type === "python" task.task_type === "python"
? "Python is run as `python3 -c`." ? "Python is run as `python3 -c`."
: "Shell commands are run through `/bin/sh -c`." : "Shell commands are run through `/bin/sh -c`."
} }
/> >
</Stack> <Textarea
</Stack> rows={9}
value={task.content}
onChange={(e) => onChange({ ...task, content: e.target.value })}
/>
</FormField>
</div>
</div>
); );
} }
@@ -200,25 +242,36 @@ function TaskDialog({
}; };
return ( return (
<Dialog open={open} onClose={requestClose} fullWidth maxWidth="md"> <Dialog
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle> open={open}
<DialogContent dividers> 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} /> <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> </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> </Dialog>
); );
} }
@@ -243,6 +296,12 @@ export function Actions() {
); );
const selectedRuns = useTaskRuns(selectedTask?.id); const selectedRuns = useTaskRuns(selectedTask?.id);
const openEdit = (initial: SavedTaskInput) => {
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
};
const createNew = () => { const createNew = () => {
const initial = emptyTask(); const initial = emptyTask();
setDraft(initial); setDraft(initial);
@@ -271,51 +330,43 @@ export function Actions() {
const editingTask = selectedTask; const editingTask = selectedTask;
return ( return (
<Stack spacing={2.25}> <div className="flex flex-col gap-6">
<Stack <div className="flex flex-row flex-wrap items-center justify-between gap-2">
direction="row" <div>
spacing={1} <h1 className="text-lg font-semibold">Actions</h1>
sx={{ <p className="text-xs text-muted-foreground">
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Actions
</Typography>
<Typography variant="body2" color="text.secondary">
Save reusable server tasks and switch between them with tabs. Save reusable server tasks and switch between them with tabs.
</Typography> </p>
</Box> </div>
<Chip label={`${tasks.length} saved`} variant="outlined" /> <Badge variant="outline">{`${tasks.length} saved`}</Badge>
</Stack> </div>
{saveTask.error && ( {saveTask.error && (
<Alert severity="error">{String(saveTask.error)}</Alert> <Alert variant="destructive">
<AlertDescription>{String(saveTask.error)}</AlertDescription>
</Alert>
)} )}
{deleteTask.error && ( {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 <div className="grid grid-cols-1 gap-4 md:grid-cols-[280px_minmax(0,1fr)]">
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "280px minmax(0, 1fr)" },
gap: 2,
}}
>
<SelectionRailCard <SelectionRailCard
title="Saved actions" title="Saved actions"
description="Pick a saved task, then edit or run it from the detail pane." description="Pick a saved task, then edit or run it from the detail pane."
contentSx={{ maxHeight: { xs: 520, md: 620 } }} contentSx={{}}
footer={ footer={
<Button <Button
variant="outlined" variant="outline"
size="small" size="sm"
fullWidth className="w-full"
onClick={createNew} onClick={createNew}
> >
Add action Add action
@@ -324,301 +375,155 @@ export function Actions() {
> >
<Tabs <Tabs
value={tab} value={tab}
onChange={(_, value) => setTab(value)} onValueChange={(value) => setTab(value)}
orientation="vertical" orientation="vertical"
variant="scrollable" className="w-full"
sx={{ borderRight: 1, borderColor: "divider" }}
> >
{tasks.map((task) => ( <TabsList variant="line" className="h-fit w-full justify-start">
<Box {tasks.map((task) => (
key={task.id} <div
sx={{ key={task.id}
position: "relative", className="group relative w-full group-hover:[&_.rail-edit]:opacity-100"
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%)",
}}
> >
<HoverEditButton <TabsTrigger
onClick={() => { value={task.id}
const initial = { className="w-full justify-start pr-9"
id: task.id, onClick={() => setTab(task.id)}
name: task.name, onDoubleClick={() => openEdit(initialFromTask(task))}
task_type: task.task_type, >
content: task.content, {task.name}
enabled: task.enabled, </TabsTrigger>
default_machine_id: task.default_machine_id, <div className="absolute top-1/2 right-1 -translate-y-1/2">
notes: task.notes, <HoverEditButton
}; onClick={() => openEdit(initialFromTask(task))}
setDraft(initial); />
setDraftBaseline(initial); </div>
setEditOpen(true); </div>
}} ))}
/> </TabsList>
</Box>
</Box>
))}
</Tabs> </Tabs>
</SelectionRailCard> </SelectionRailCard>
<Stack spacing={2}> <div className="flex flex-col gap-4">
{editingTask ? ( {editingTask ? (
<Card variant="outlined"> <SectionCard
<CardContent sx={{ p: 1.5 }}> title={editingTask.name}
<Stack spacing={1.5}> description="Open the editor popup to modify this action."
<Stack action={
direction="row" <div className="flex flex-row flex-wrap items-center gap-2">
spacing={1} <Button
sx={{ variant="outline"
alignItems: "center", onClick={() => openEdit(initialFromTask(editingTask))}
justifyContent: "space-between", >
flexWrap: "wrap", Edit
</Button>
<Button
disabled={runTask.isPending || !runMachineId}
onClick={async () => {
await runTask.mutateAsync({
taskId: editingTask.id,
machineId: runMachineId,
});
}} }}
> >
<Box> {runTask.isPending ? "Running..." : "Run action"}
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> </Button>
{editingTask.name} </div>
</Typography> }
<Typography variant="body2" color="text.secondary"> >
Open the editor popup to modify this action. <div className="flex flex-wrap items-center gap-2">
</Typography> <FormField label="Run on machine">
</Box> <Select
<Stack value={runMachineId}
direction="row" onValueChange={(value) => setRunMachineId(value)}
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" }}
> >
<FormControl size="small" sx={{ minWidth: 240 }}> <SelectTrigger className="min-w-[240px]" size="sm">
<InputLabel>Run on machine</InputLabel> <SelectValue placeholder="Select machine" />
<Select </SelectTrigger>
label="Run on machine" <SelectContent>
value={runMachineId} {machines.map((machine) => (
onChange={(e) => <SelectItem key={machine.id} value={machine.id}>
setRunMachineId(String(e.target.value)) {machine.name}
} </SelectItem>
>
{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>
))} ))}
</Stack> </SelectContent>
) : ( </Select>
<Alert severity="info">No runs yet.</Alert> </FormField>
)} </div>
</Stack>
</CardContent> <Separator />
</Card>
<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}> <div className="flex flex-col gap-4">
<Card variant="outlined"> <SectionCard
<CardContent sx={{ p: 2 }}> title="No action selected"
<Stack spacing={1.25}> 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."
<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>
{tasks[0] && ( {tasks[0] && (
<Button <Button variant="outline" onClick={() => setTab(tasks[0].id)}>
variant="outlined"
onClick={() => setTab(tasks[0].id)}
>
Select first action Select first action
</Button> </Button>
)} )}
</Stack> </SectionCard>
</CardContent>
</Card>
<Card variant="outlined"> <SectionCard title="What this panel shows">
<CardContent sx={{ p: 2 }}> <p className="text-xs text-muted-foreground">
<Stack spacing={1}> Saved actions stay on the left rail, while details, run
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> controls, and recent history appear here.
What this panel shows </p>
</Typography> </SectionCard>
<Typography variant="body2" color="text.secondary"> </div>
Saved actions stay on the left rail, while details, run
controls, and recent history appear here.
</Typography>
</Stack>
</CardContent>
</Card>
</Stack>
)} )}
</Stack> </div>
</Box> </div>
<TaskDialog <TaskDialog
open={editOpen} open={editOpen}
@@ -632,6 +537,6 @@ export function Actions() {
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined 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");
});
});
@@ -616,3 +616,151 @@ Overall change `applyState` is still reported **blocked** by the status engine
Slice 4 migration itself — `design.md` §1 provided the authoritative component Slice 4 migration itself — `design.md` §1 provided the authoritative component
mapping and `actionContext` is `repo-local` with `allowedEditRoots` covering the mapping and `actionContext` is `repo-local` with `allowedEditRoots` covering the
workspace. Should be resolved before `sdd-verify`/archive, per the slice-3 note. workspace. Should be resolved before `sdd-verify`/archive, per the slice-3 note.
## Slice 5 — Settings + Actions (form-heavy pair) — COMPLETE
Migrated the two form-heavy pages off `@mui/material` onto the shadcn/Tailwind
design system per `design.md` §1. All 5 Slice-5 tasks in `tasks.md` are now
marked `- [x]`. **No form library introduced** — every field stays controlled
`useState`, exactly as before.
### Completed tasks (persisted checkboxes updated)
- [x] **`frontend/src/pages/Actions.tsx`** — MUI-free. `Tab/Tabs` → shadcn `Tabs`/
`TabsList`/`TabsTrigger` (vertical orientation in the saved-actions rail);
`FormControl`/`InputLabel`/`Select`/`MenuItem` → shadcn `Select` family (with a
`NONE = "__none__"` sentinel for the empty "None" option, since Radix Select
disallows empty-string item values); `Divider` → `Separator`; `Dialog` family →
shadcn `Dialog`. Saved-task editor (shell/python `Textarea`, default-machine
`Select`), run-machine selection, and the recent-runs list are preserved.
Reuses `SelectionRailCard`, `SectionCard` (detail + empty states),
`HoverEditButton`, `DialogFooter`. `TextField`→`Input`+`Label` via a file-local
`FormField` helper.
- [x] **`frontend/src/pages/Settings.tsx`** — MUI-free. MUI `Grid` → 12-col CSS
grid (`grid grid-cols-12` + `col-span-*`); `Switch` → shadcn `Switch`
(`onCheckedChange`); `Checkbox`+`FormControlLabel` → `Checkbox` + native
`<label>`/`Label` rows; `Tab` → `TabsTrigger` inside `TabbedCard`; `Select` →
shadcn `Select` (incl. the SSH-key picker with the `NONE` sentinel);
`Dialog`/`DialogTitle`/`DialogContent` → shadcn `Dialog`. Monitoring-machine CRUD
(rail + detail + edit dialog), SSH-key management (rail + SectionCard editor +
generate/save/delete), SSH test/validation feedback (status line + success/error
`Alert`s, exact message strings preserved incl. the protocol-banner /
auth-failed / generic branches), and danger-zone database reset (3 acks + typed
confirm phrase gating `confirmDisabled`, via the shared `ConfirmDialog`) are all
preserved. Reuses `SectionCard`, `SelectionRailCard`, `TabbedCard`,
`HoverEditButton`, `DialogFooter`, `ConfirmDialog`.
- [x] **Form-behavior parity** — all hook usage (`useMonitoringSettings`,
`useSSHKeys`, `useSave*`, `useDelete*`, `useGenerateSSHKey`, `useTestMonitoringMachineSSH`,
`useResetLocalDatabase`, `useTasks`, `useTaskRuns`, `useRunTask`) and controlled
`useState` drafts are unchanged. No form library.
- [x] **Tests** — `src/pages/__tests__/Settings.test.tsx` (renders machine list
from mocked store; saves a renamed machine via the editor dialog; deletes a
machine through the confirm dialog) and `src/pages/__tests__/Actions.test.tsx`
(empty state + create/save task via editor; `Run action` button disabled until a
run machine is selected). Hooks mocked with `vi.mock`; no live SSH needed.
- [x] **Exit gate** — `npm run build` + `npm run lint` + `npm test` all exit 0;
`npm run test:node` (`node --test tests/*.test.mjs`) green (4/4). Verified
below.
### Files changed (this slice)
- `frontend/src/pages/Settings.tsx` (rewrite; MUI → shadcn/Tailwind)
- `frontend/src/pages/Actions.tsx` (rewrite; MUI → shadcn/Tailwind)
- `frontend/src/pages/__tests__/Settings.test.tsx` (new)
- `frontend/src/pages/__tests__/Actions.test.tsx` (new)
No other page or `components/*` file was edited (scope-clean confirmed via
`git status`). Shared components (`SectionCard`, `SelectionRailCard`,
`TabbedCard`, `HoverEditButton`, `ConfirmDialog`, `DialogFooter`) were reused
unchanged.
### Gate results (run from `frontend/`)
| Command | Result | Notes |
|---|---|---|
| `npm run build` (`tsc -b && vite build`) | **pass (exit 0)** | 3313 modules; pre-existing >500 kB chunk warning only. |
| `npm run lint` (`eslint .`) | **pass (exit 0)** | 0 errors. 2 pre-existing `react-hooks/exhaustive-deps` warnings live in `UsersPage.impl.tsx` (slice 6, out of scope). |
| `npm test` (`vitest run`) | **pass (exit 0)** | 19 files / 39 tests, including the 5 new Settings+Actions tests. |
| `npm run test:node` (`node --test tests/*.test.mjs`) | **pass (exit 0)** | 4/4 node suites (untouched). |
| `grep @mui/(material\|icons-material\|x-data-grid)` over both pages | **BOTH-MUI-FREE** | Hard gate satisfied. |
> Note on `node --test tests`: the literal `node --test tests` invocation in the
> tasks.md exit-gate line mis-resolves `tests` as a module entry (`Cannot find
> module '.../tests'`). The working command is the npm script
> `test:node` = `node --test tests/*.test.mjs` (matches slice-1's harness), which
> is green. `npm test` covers the component suite.
### Deviations from design / notes
- **`Alert` variant surface:** the shadcn `Alert` primitive only exposes
`default` + `destructive`. MUI `severity="info"/"success"/"warning"` map to the
default `Alert` and `severity="error"` maps to `destructive`. All SSH
validation / info / warning **message text is preserved verbatim** (behavior
parity); only the severity→color cue is flattened to the two available Alert
variants.
- **Badge cues:** MUI `Chip variant="outlined"``Badge variant="outline"`; the
danger-zone "Destructive" chip and destructive buttons → `Badge`/`Button
variant="destructive"`. Status chips (enabled/disabled/service counts/run
status) use `outline`. No `success` cue was needed at these call sites (run
status is a free-text string), consistent with design §2.3.
- **Radix Select empty value:** `NONE = "__none__"` sentinel is converted to `""`
at the draft boundary for both the SSH-key picker (Settings) and the
default-machine picker (Actions). Required because Radix Select rejects
empty-string item values.
- **Hover-reveal edit affordance:** `HoverEditButton` (not editable this slice)
ships with a baked `opacity-0` + `rail-edit` class. Since the MUI
`&:hover .rail-edit` sx rules are gone, each rail row now carries a Tailwind
`group` + arbitrary-variant `group-hover:[&_.rail-edit]:opacity-100` to restore
the hover-reveal (verified the utility is generated by the Tailwind v4 build).
- **Dialog widths:** MUI `maxWidth``sm:max-w-4xl` (machine editor), `sm:max-w-2xl`
(task editor), `sm:max-w-md` (danger-zone reset). A `DialogDescription` was
added to each shadcn dialog (a11y + avoids the Radix "missing description" dev
warning) without changing behavior.
### SSH-validation / form behavior worth flagging
- The `validateMachineSSH` flow, its `try/catch`, the `known_hosts_updated`
status-string branch, and the three lowered-message status branches
(protocol-banner / auth-failed / generic) are byte-for-byte preserved.
- `saveMachineDraft` still clears SSH validation, awaits `mutateAsync`, then closes
the dialog and resets the draft to `emptyMachine(mode)`.
- The danger-zone reset `canSubmit` gate (exact phrase match + all 3 acks) is
preserved and wired to `DialogFooter.confirmDisabled`.
### Remaining tasks (exact unchecked `- [ ]` lines)
All Slice-5 lines are `[x]`. The remaining 28 unchecked lines are Slice 6
(Users: table/drawer/compose + 9 lucide icons), Slice 7 (`DataTable` + Media +
FileBrowser off `@mui/x-data-grid`), and Slice 8 (package.json cleanup + grep
gates + `docs/REQUIREMENTS.md`). No slice-5 work remains.
### Workload / PR boundary
Single slice, under the 400-line added budget: ~1097 insertions / ~1337 deletions
across the two page rewrites (net 240 — the migration is more compact) + 2 new
test files (~150 lines). The parent owns the commit/PR; nothing committed here.
### Top risk for slice 6
**`UsersPage.impl.tsx`** is the largest consumer (25 MUI components + 9
`@mui/icons-material` icons + `Drawer``Sheet` + rich-text compose with
file attachments + selection-across-pagination). Per `design.md` §8 it has a
**high sub-split likelihood**; expect a 6a (directory table + selection + drawer)
→ 6b (compose dialog + formatting actions + attachments) split to stay ≤400
lines. The icon-name pin (`Close→X`, `AttachFile→Paperclip`, `FormatBold→Bold`,
`FormatItalic→Italic`, `Link→Link`, `FormatListBulleted→List`, `MailOutlined→Mail`,
`Send→Send`, `DeleteOutlined→Trash2`) must be verified at the lucide-react
`^1.14.0` pin before authoring. Selection-across-pagination semantics
(`selectedUserIds` surviving paging/filtering) and the rich-text compose behavior
(queue-status polling + `FormData` attachments + `useSendUserMessage`) are the
behavior-parity surfaces to guard.
### Structured status note
Overall change `applyState` remains **blocked** per the status engine (domain
specs missing/partial; legacy flat `spec.md`). This is a planning-completeness
gap only — `design.md` §1 supplied the authoritative MUI→shadcn mapping and
`actionContext` is `repo-local` with `allowedEditRoots: ["/home/user/Manage_01"]`.
The parent explicitly delegated Slice 5 with a clear scope/delivery path, so this
slice proceeded under that delegation. Should be resolved before
`sdd-verify`/archive, per the slice-3/slice-4 notes.
+5 -5
View File
@@ -176,11 +176,11 @@ Each slice section restates this gate as its final task.
> ~350500 lines, medium-high. Depends on slice 2. Keep uncontrolled/`useState` form > ~350500 lines, medium-high. Depends on slice 2. Keep uncontrolled/`useState` form
> parity — NO form library. Split 5a (Actions) → 5b (Settings) if over 400. > parity — NO form library. Split 5a (Actions) → 5b (Settings) if over 400.
- [ ] Migrate `frontend/src/pages/Actions.tsx` (19 MUI components incl. Tab/Tabs/Select/MenuItem/FormControl/InputLabel/Divider/Dialog → shadcn `Tabs`/`Select`/`Separator`/`Dialog`; saved-task editor, machine selection, run history preserved). - [x] Migrate `frontend/src/pages/Actions.tsx` (19 MUI components incl. Tab/Tabs/Select/MenuItem/FormControl/InputLabel/Divider/Dialog → shadcn `Tabs`/`Select`/`Separator`/`Dialog`; saved-task editor, machine selection, run history preserved).
- [ ] Migrate `frontend/src/pages/Settings.tsx` (18 MUI components incl. Grid/Switch/Checkbox/FormControlLabel/Tab/Select/Dialog → CSS grid/`Switch`/`Checkbox`/`Label`/`Tabs`/`Select`/`Dialog`; monitoring-machine CRUD, SSH-key management, SSH test/validation feedback, danger-zone reset, tabbed UI preserved). - [x] Migrate `frontend/src/pages/Settings.tsx` (18 MUI components incl. Grid/Switch/Checkbox/FormControlLabel/Tab/Select/Dialog → CSS grid/`Switch`/`Checkbox`/`Label`/`Tabs`/`Select`/`Dialog`; monitoring-machine CRUD, SSH-key management, SSH test/validation feedback, danger-zone reset, tabbed UI preserved).
- [ ] Keep all current form behaviors (controlled `useState`, SSH validation messages, ConfirmDialog integration from slice 2) — no form-library introduction. - [x] Keep all current form behaviors (controlled `useState`, SSH validation messages, ConfirmDialog integration from slice 2) — no form-library introduction.
- [ ] Add component tests for the migrated Settings (machine save/delete confirm) and Actions (save/run task) where behavior is exercisable without live SSH. - [x] Add component tests for the migrated Settings (machine save/delete confirm) and Actions (save/run task) where behavior is exercisable without live SSH.
- [ ] **Exit gate:** Settings + Actions MUI-free; forms behave as before; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green. - [x] **Exit gate:** Settings + Actions MUI-free; forms behave as before; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green.
--- ---