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:
+338
-433
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+759
-911
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user