feat(FN-1081): add dashboard nodes management workflow
- Add node API typings and client methods plus a polling useNodes hook with visibility-triggered refresh - Introduce NodesView with NodeCard, AddNodeModal, and NodeDetailModal to register, inspect, update, health-check, and remove nodes - Wire the Nodes surface into the app shell/header and add project node assignment UI with project card node badges - Add unit coverage for useNodes, NodesView, NodeCard, Header, and ProjectCard behavior, plus node-specific dashboard styling
This commit is contained in:
147
packages/dashboard/app/components/__tests__/NodeCard.test.tsx
Normal file
147
packages/dashboard/app/components/__tests__/NodeCard.test.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { NodeCard } from "../NodeCard";
|
||||
import type { NodeInfo, ProjectInfo } from "../../api";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Activity: () => <span data-testid="activity-icon">activity</span>,
|
||||
Server: () => <span data-testid="server-icon">server</span>,
|
||||
Settings: () => <span data-testid="settings-icon">settings</span>,
|
||||
Trash2: () => <span data-testid="trash-icon">trash</span>,
|
||||
}));
|
||||
|
||||
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||
return {
|
||||
id: "node-1",
|
||||
name: "Primary Node",
|
||||
type: "local",
|
||||
status: "online",
|
||||
maxConcurrent: 3,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
return {
|
||||
id: "proj-1",
|
||||
name: "Project One",
|
||||
path: "/workspace/project-one",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("NodeCard", () => {
|
||||
it("renders node name, type, status, project count, and concurrency", () => {
|
||||
const node = makeNode({ id: "node-abc", name: "Build Worker", type: "remote", status: "connecting", url: "https://remote.example.com" });
|
||||
const projects = [
|
||||
makeProject({ id: "proj-a", nodeId: "node-abc" }),
|
||||
makeProject({ id: "proj-b", nodeId: "node-abc" }),
|
||||
makeProject({ id: "proj-c", nodeId: "other-node" }),
|
||||
];
|
||||
|
||||
render(
|
||||
<NodeCard
|
||||
node={node}
|
||||
projects={projects}
|
||||
onHealthCheck={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Build Worker")).toBeDefined();
|
||||
expect(screen.getByText("Remote")).toBeDefined();
|
||||
expect(screen.getByText("Connecting")).toBeDefined();
|
||||
expect(screen.getByText("2")).toBeDefined();
|
||||
expect(screen.getByText("3")).toBeDefined();
|
||||
expect(screen.getByText("https://remote.example.com")).toBeDefined();
|
||||
});
|
||||
|
||||
it("maps status classes correctly", () => {
|
||||
const { rerender } = render(
|
||||
<NodeCard
|
||||
node={makeNode({ status: "online" })}
|
||||
projects={[]}
|
||||
onHealthCheck={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Online").className).toContain("node-card__status--online");
|
||||
|
||||
rerender(
|
||||
<NodeCard
|
||||
node={makeNode({ status: "offline" })}
|
||||
projects={[]}
|
||||
onHealthCheck={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Offline").className).toContain("node-card__status--offline");
|
||||
|
||||
rerender(
|
||||
<NodeCard
|
||||
node={makeNode({ status: "error" })}
|
||||
projects={[]}
|
||||
onHealthCheck={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Error").className).toContain("node-card__status--error");
|
||||
});
|
||||
|
||||
it("fires health check and edit callbacks", () => {
|
||||
const node = makeNode();
|
||||
const onHealthCheck = vi.fn();
|
||||
const onEdit = vi.fn();
|
||||
|
||||
render(
|
||||
<NodeCard
|
||||
node={node}
|
||||
projects={[]}
|
||||
onHealthCheck={onHealthCheck}
|
||||
onEdit={onEdit}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Run node health check"));
|
||||
expect(onHealthCheck).toHaveBeenCalledWith(node.id);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Edit node"));
|
||||
expect(onEdit).toHaveBeenCalledWith(node);
|
||||
});
|
||||
|
||||
it("requires a second click to confirm remove", () => {
|
||||
const onRemove = vi.fn();
|
||||
const node = makeNode();
|
||||
|
||||
render(
|
||||
<NodeCard
|
||||
node={node}
|
||||
projects={[]}
|
||||
onHealthCheck={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
const removeButton = screen.getByLabelText("Remove node");
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
expect(onRemove).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Confirm")).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Confirm remove node"));
|
||||
expect(onRemove).toHaveBeenCalledWith(node.id);
|
||||
});
|
||||
});
|
||||
139
packages/dashboard/app/components/__tests__/NodesView.test.tsx
Normal file
139
packages/dashboard/app/components/__tests__/NodesView.test.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { NodesView } from "../NodesView";
|
||||
import type { NodeInfo, ProjectInfo } from "../../api";
|
||||
import { useNodes } from "../../hooks/useNodes";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
|
||||
vi.mock("../../hooks/useNodes", () => ({
|
||||
useNodes: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useProjects", () => ({
|
||||
useProjects: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseNodes = vi.mocked(useNodes);
|
||||
const mockUseProjects = vi.mocked(useProjects);
|
||||
|
||||
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||
return {
|
||||
id: "node-1",
|
||||
name: "Primary Node",
|
||||
type: "local",
|
||||
status: "online",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
return {
|
||||
id: "proj-1",
|
||||
name: "Project One",
|
||||
path: "/workspace/project-one",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeUseNodesResult(overrides: Partial<ReturnType<typeof useNodes>> = {}): ReturnType<typeof useNodes> {
|
||||
return {
|
||||
nodes: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
register: vi.fn().mockResolvedValue(makeNode()),
|
||||
update: vi.fn().mockResolvedValue(makeNode()),
|
||||
unregister: vi.fn().mockResolvedValue(undefined),
|
||||
healthCheck: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseProjects.mockReturnValue({
|
||||
projects: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
register: vi.fn(),
|
||||
update: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodesView", () => {
|
||||
it("renders node cards and stats", () => {
|
||||
mockUseProjects.mockReturnValue({
|
||||
projects: [makeProject({ nodeId: "node-1" }), makeProject({ id: "proj-2", nodeId: "node-2" })],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
register: vi.fn(),
|
||||
update: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseNodes.mockReturnValue(makeUseNodesResult({
|
||||
nodes: [
|
||||
makeNode({ id: "node-1", name: "Alpha", status: "online", type: "local" }),
|
||||
makeNode({ id: "node-2", name: "Beta", status: "offline", type: "remote", url: "https://beta.node" }),
|
||||
],
|
||||
}));
|
||||
|
||||
render(<NodesView addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Alpha")).toBeDefined();
|
||||
expect(screen.getByText("Beta")).toBeDefined();
|
||||
expect(screen.getByText("2 registered")).toBeDefined();
|
||||
expect(screen.getByTestId("nodes-stat-total").textContent).toContain("2");
|
||||
expect(screen.getByTestId("nodes-stat-online").textContent).toContain("1");
|
||||
expect(screen.getByTestId("nodes-stat-offline").textContent).toContain("1");
|
||||
expect(screen.getByTestId("nodes-stat-remote").textContent).toContain("1");
|
||||
});
|
||||
|
||||
it("renders empty state when there are no nodes", () => {
|
||||
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [] }));
|
||||
|
||||
render(<NodesView addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("No nodes are registered yet.")).toBeDefined();
|
||||
expect(screen.getByText("Add First Node")).toBeDefined();
|
||||
});
|
||||
|
||||
it("opens Add Node modal when Add Node button is clicked", () => {
|
||||
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [] }));
|
||||
|
||||
render(<NodesView addToast={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Add Node"));
|
||||
expect(screen.getByRole("dialog", { name: "Add Node" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("opens Node Detail modal when a node card is clicked", () => {
|
||||
mockUseProjects.mockReturnValue({
|
||||
projects: [makeProject({ nodeId: "node-1" })],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
register: vi.fn(),
|
||||
update: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseNodes.mockReturnValue(makeUseNodesResult({
|
||||
nodes: [makeNode({ id: "node-1", name: "Alpha Node" })],
|
||||
}));
|
||||
|
||||
render(<NodesView addToast={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Alpha Node"));
|
||||
expect(screen.getByRole("dialog", { name: "Node details for Alpha Node" })).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ProjectCard } from "../ProjectCard";
|
||||
import type { RegisteredProject, ProjectHealth } from "@fusion/core";
|
||||
import type { NodeInfo } from "../../api";
|
||||
|
||||
// Mock lucide-react to avoid SVG rendering issues in test env
|
||||
vi.mock("lucide-react", () => ({
|
||||
@@ -41,6 +42,19 @@ function makeHealth(overrides: Partial<ProjectHealth> = {}): ProjectHealth {
|
||||
};
|
||||
}
|
||||
|
||||
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||
return {
|
||||
id: "node_001",
|
||||
name: "Build Node",
|
||||
type: "local",
|
||||
status: "online",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("ProjectCard", () => {
|
||||
@@ -60,6 +74,22 @@ describe("ProjectCard", () => {
|
||||
expect(screen.getByText("/path/to/project")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders assigned node badge when node is provided", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject()}
|
||||
health={makeHealth()}
|
||||
node={makeNode({ name: "Remote Worker" })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("on: Remote Worker")).toBeDefined();
|
||||
});
|
||||
|
||||
it("truncates long paths", () => {
|
||||
const longPath = "/very/long/path/to/the/project/directory/that/needs/truncation";
|
||||
render(
|
||||
|
||||
Reference in New Issue
Block a user