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
@@ -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");
});
});