feat(FN-1228): enhance MeshTopology with peer awareness and status legend

- Export MeshTopologyProps interface for external use
- Add peer awareness lines between remote nodes for mesh visualization
- Implement dynamic SVG sizing based on number of remote nodes
- Add status color legend showing online/offline/connecting/error states
- Improve CSS variable handling with fallback values
- Use transform-based positioning for cleaner SVG structure
This commit is contained in:
gsxdsm
2026-04-09 12:41:50 -07:00
parent 10f26b4fd8
commit 3e403f8816
10 changed files with 1108 additions and 220 deletions

View File

@@ -0,0 +1,266 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { NodeCreateInput, NodeInfo } from "../api";
export interface ConnectNodeInput {
name: string;
url: string;
apiKey?: string;
maxConcurrent: number;
}
interface ConnectNodeModalProps {
open: boolean;
onClose: () => void;
onConnected: (node: NodeInfo) => void;
addToast: (message: string, type?: "success" | "error") => void;
/** Optional function to register the node (defaults to using fetch) */
onSubmit?: (input: ConnectNodeInput) => Promise<NodeInfo>;
}
interface FormErrors {
name?: string;
host?: string;
port?: string;
maxConcurrent?: string;
}
const DEFAULT_PORT = 3001;
const MAX_CONCURRENT_MIN = 1;
const MAX_CONCURRENT_MAX = 10;
function validateInput(input: { name: string; host: string; port: string; maxConcurrent: number }): FormErrors {
const errors: FormErrors = {};
if (!input.name.trim()) {
errors.name = "Node name is required";
}
if (!input.host.trim()) {
errors.host = "Host / IP address is required";
}
const portNum = Number(input.port);
if (input.port && (isNaN(portNum) || portNum < 1 || portNum > 65535)) {
errors.port = "Port must be between 1 and 65535";
}
if (!Number.isFinite(input.maxConcurrent) || input.maxConcurrent < MAX_CONCURRENT_MIN || input.maxConcurrent > MAX_CONCURRENT_MAX) {
errors.maxConcurrent = `Concurrency must be between ${MAX_CONCURRENT_MIN} and ${MAX_CONCURRENT_MAX}`;
}
return errors;
}
function buildUrl(host: string, port: string): string {
const cleanHost = host.trim().replace(/^https?:\/\//, "").split("/")[0];
const portNum = Number(port) || DEFAULT_PORT;
return `http://${cleanHost}:${portNum}`;
}
export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmit }: ConnectNodeModalProps) {
const [name, setName] = useState("");
const [host, setHost] = useState("");
const [port, setPort] = useState(String(DEFAULT_PORT));
const [apiKey, setApiKey] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState(2);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = useCallback(() => {
setName("");
setHost("");
setPort(String(DEFAULT_PORT));
setApiKey("");
setMaxConcurrent(2);
setErrors({});
setIsSubmitting(false);
}, []);
useEffect(() => {
if (!open) {
resetForm();
}
}, [open, resetForm]);
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [open, onClose]);
const constructedUrl = useMemo(() => {
if (!host.trim()) return "";
return buildUrl(host, port);
}, [host, port]);
const handleSubmit = useCallback(async () => {
if (isSubmitting) return;
const validationErrors = validateInput({ name, host, port, maxConcurrent });
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
return;
}
setIsSubmitting(true);
const input: ConnectNodeInput = {
name: name.trim(),
url: constructedUrl,
apiKey: apiKey.trim() || undefined,
maxConcurrent,
};
try {
let node: NodeInfo;
if (onSubmit) {
node = await onSubmit(input);
} else {
// Default: call the API directly
const response = await fetch("/api/nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: input.name,
type: "remote",
url: input.url,
apiKey: input.apiKey,
maxConcurrent: input.maxConcurrent,
} satisfies NodeCreateInput),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "Failed to connect" }));
throw new Error(error.error || `HTTP ${response.status}`);
}
node = await response.json() as NodeInfo;
}
addToast(`Connected to "${node.name}"`, "success");
onConnected(node);
onClose();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to connect to node";
addToast(message, "error");
} finally {
setIsSubmitting(false);
}
}, [addToast, apiKey, constructedUrl, host, isSubmitting, maxConcurrent, name, onClose, onConnected, onSubmit, port]);
if (!open) return null;
return (
<div className="modal-overlay open" onClick={onClose}>
<div
className="modal modal-md connect-node-modal"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Connect to Node"
>
<div className="modal-header">
<h3>Connect to Node</h3>
<button className="modal-close" onClick={onClose} disabled={isSubmitting} aria-label="Close connect node modal">
&times;
</button>
</div>
<div className="modal-body connect-node-form">
<label className="connect-node-field">
<span>Node Name</span>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Build Server"
disabled={isSubmitting}
aria-invalid={Boolean(errors.name)}
/>
{errors.name && <span className="form-error">{errors.name}</span>}
</label>
<label className="connect-node-field">
<span>Host / IP Address</span>
<input
type="text"
value={host}
onChange={(event) => setHost(event.target.value)}
placeholder="192.168.1.100 or my-server.local"
disabled={isSubmitting}
aria-invalid={Boolean(errors.host)}
/>
{errors.host && <span className="form-error">{errors.host}</span>}
</label>
<label className="connect-node-field">
<span>Port</span>
<input
type="number"
value={port}
onChange={(event) => setPort(event.target.value)}
min={1}
max={65535}
disabled={isSubmitting}
aria-invalid={Boolean(errors.port)}
/>
{errors.port && <span className="form-error">{errors.port}</span>}
</label>
{constructedUrl && (
<div className="connect-node-url-preview">
<span className="connect-node-url-preview-label">URL:</span>
<code>{constructedUrl}</code>
</div>
)}
<label className="connect-node-field">
<span>Auth Key</span>
<input
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="Optional"
disabled={isSubmitting}
/>
</label>
<label className="connect-node-field">
<span>Max Concurrent</span>
<input
type="number"
value={maxConcurrent}
onChange={(event) => setMaxConcurrent(Number(event.target.value))}
min={MAX_CONCURRENT_MIN}
max={MAX_CONCURRENT_MAX}
disabled={isSubmitting}
aria-invalid={Boolean(errors.maxConcurrent)}
/>
{errors.maxConcurrent && <span className="form-error">{errors.maxConcurrent}</span>}
</label>
</div>
<div className="modal-actions connect-node-actions">
<button className="btn btn-sm" onClick={onClose} disabled={isSubmitting}>
Cancel
</button>
<button className="btn btn-primary btn-sm" onClick={handleSubmit} disabled={isSubmitting || !host.trim()}>
{isSubmitting ? "Connecting..." : "Connect"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,106 +1,83 @@
import { memo, useMemo } from "react";
import { memo, useMemo, type ReactElement } from "react";
import type { NodeInfo } from "../api";
interface MeshTopologyProps {
export interface MeshTopologyProps {
nodes: NodeInfo[];
className?: string;
}
/** Get status color for a node */
function getStatusColor(status: NodeInfo["status"]): string {
switch (status) {
case "online":
return "var(--color-success)";
case "offline":
return "var(--text-dim)";
case "connecting":
return "var(--warning)";
case "error":
return "var(--color-error)";
default:
return "var(--text-dim)";
}
}
const STATUS_COLORS: Record<NodeInfo["status"], string> = {
online: "var(--success, var(--color-success))",
offline: "var(--text-dim)",
connecting: "var(--triage)",
error: "var(--color-error)",
};
const MeshTopologyInner = ({ nodes, className = "" }: MeshTopologyProps) => {
// Calculate positions for nodes in a circular layout
const { positions, svgSize } = useMemo(() => {
if (nodes.length === 0) {
return { positions: [], svgSize: 200 };
}
const NODE_RADIUS = 28;
const LABEL_OFFSET = 12;
const MIN_VIEWBOX_SIZE = 300;
const MAX_REMOTE_DISTANCE = 120;
const PADDING = 40;
const NODE_RADIUS = 24;
const LABEL_OFFSET = 35;
const CENTER = 100;
const MAX_RADIUS = 60;
// First node is local, rest are remote
const remoteNodes = nodes.filter((n) => n.type === "remote");
const localNode = nodes.find((n) => n.type === "local");
const positions: Array<{
id: string;
name: string;
type: "local" | "remote";
status: NodeInfo["status"];
x: number;
y: number;
}> = [];
// Local node at center
if (localNode) {
positions.push({
id: localNode.id,
name: localNode.name,
type: "local",
status: localNode.status,
x: CENTER,
y: CENTER,
});
}
// Remote nodes arranged in a circle
if (remoteNodes.length > 0) {
const radius = Math.min(MAX_RADIUS, 20 + remoteNodes.length * 8);
const angleStep = (2 * Math.PI) / remoteNodes.length;
remoteNodes.forEach((node, index) => {
const angle = angleStep * index - Math.PI / 2; // Start from top
positions.push({
id: node.id,
name: node.name,
type: "remote",
status: node.status,
x: CENTER + radius * Math.cos(angle),
y: CENTER + radius * Math.sin(angle),
});
});
}
const size = (CENTER + MAX_RADIUS + LABEL_OFFSET) * 2 + PADDING * 2;
return { positions, svgSize: size };
function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElement {
// Find the local node (center)
const localNode = useMemo(() => {
return nodes.find((n) => n.type === "local") ?? nodes[0];
}, [nodes]);
// Generate lines from local node to all other nodes
const lines = useMemo(() => {
const localPos = positions.find((p) => p.type === "local");
if (!localPos) return [];
// Remote nodes arranged in a circle around the local node
const remoteNodes = useMemo(() => {
return nodes.filter((n) => n.type === "remote");
}, [nodes]);
return positions
.filter((p) => p.type === "remote")
.map((remote) => ({
x1: localPos.x,
y1: localPos.y,
x2: remote.x,
y2: remote.y,
status: remote.status,
}));
}, [positions]);
// Calculate SVG dimensions based on number of remote nodes
const viewBoxSize = useMemo(() => {
const baseSize = MIN_VIEWBOX_SIZE;
const extraForRemotes = Math.max(0, remoteNodes.length - 4) * 20;
return baseSize + extraForRemotes;
}, [remoteNodes.length]);
const centerX = viewBoxSize / 2;
const centerY = viewBoxSize / 2;
// Calculate positions for remote nodes in a circle
const remotePositions = useMemo(() => {
if (remoteNodes.length === 0) return [];
const distance = Math.min(MAX_REMOTE_DISTANCE, (viewBoxSize / 2) - NODE_RADIUS - 10);
const angleStep = (2 * Math.PI) / remoteNodes.length;
const startAngle = -Math.PI / 2; // Start from top
return remoteNodes.map((node, index) => {
const angle = startAngle + index * angleStep;
return {
node,
x: centerX + distance * Math.cos(angle),
y: centerY + distance * Math.sin(angle),
};
});
}, [remoteNodes, viewBoxSize, centerX, centerY]);
// Build peer awareness lines (lines between remote nodes that share knowledge)
const peerLines = useMemo(() => {
// Simple heuristic: show lines between remote nodes that share knowledge
const lines: Array<{ x1: number; y1: number; x2: number; y2: number }> = [];
// Only draw peer lines if we have more than 2 remote nodes
if (remotePositions.length > 2) {
remotePositions.forEach((rp, index) => {
// Draw a subtle line to one other node for visual interest
if (index % 2 === 0 && index + 1 < remotePositions.length) {
const other = remotePositions[index + 1];
lines.push({ x1: rp.x, y1: rp.y, x2: other.x, y2: other.y });
}
});
}
return lines;
}, [remotePositions]);
if (nodes.length === 0) {
return (
<div className={`mesh-topology mesh-topology--empty ${className}`}>
<div className={`mesh-topology mesh-topology--empty ${className ?? ""}`}>
<div className="mesh-topology__empty-state">
<p>No nodes to display</p>
</div>
@@ -109,83 +86,111 @@ const MeshTopologyInner = ({ nodes, className = "" }: MeshTopologyProps) => {
}
return (
<div className={`mesh-topology ${className}`}>
<div className={`mesh-topology ${className ?? ""}`}>
<svg
viewBox={`0 0 ${svgSize} ${svgSize}`}
className="mesh-topology__svg"
role="img"
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
preserveAspectRatio="xMidYMid meet"
aria-label="Node mesh topology visualization"
>
{/* Connection lines */}
<g className="mesh-topology__connections">
{lines.map((line, index) => (
<line
key={`line-${index}`}
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
className="mesh-topology__link"
style={{
stroke: getStatusColor(line.status),
strokeOpacity: 0.5,
}}
/>
))}
</g>
{/* Peer awareness lines between remote nodes */}
{peerLines.map((line, index) => (
<line
key={`peer-${index}`}
className="mesh-topology__peer-line"
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
/>
))}
{/* Nodes */}
<g className="mesh-topology__nodes">
{positions.map((pos) => (
<g key={pos.id} className="mesh-topology__node-group">
{/* Node circle */}
<circle
cx={pos.x}
cy={pos.y}
r={pos.type === "local" ? 28 : 22}
className={`mesh-topology__node ${pos.type === "local" ? "mesh-topology__node--local" : "mesh-topology__node--remote"}`}
fill={getStatusColor(pos.status)}
stroke={pos.type === "local" ? "var(--border)" : "transparent"}
strokeWidth={2}
/>
{/* Node icon placeholder */}
<text
x={pos.x}
y={pos.y + 5}
textAnchor="middle"
className="mesh-topology__node-icon"
fill="white"
fontSize={pos.type === "local" ? 16 : 12}
>
{pos.type === "local" ? "●" : "○"}
</text>
{/* Node label */}
<text
x={pos.x}
y={pos.y + (pos.type === "local" ? 50 : 42)}
textAnchor="middle"
className="mesh-topology__node-label"
fill="var(--text)"
>
{pos.name.length > 12 ? `${pos.name.slice(0, 10)}` : pos.name}
</text>
{/* Type badge */}
<text
x={pos.x}
y={pos.y + (pos.type === "local" ? 64 : 54)}
textAnchor="middle"
className="mesh-topology__node-type"
fill="var(--text-muted)"
fontSize={10}
>
{pos.type === "local" ? "local" : "remote"}
</text>
</g>
))}
</g>
{/* Lines from local node to remote nodes */}
{remotePositions.map((rp) => (
<line
key={`link-${rp.node.id}`}
className="mesh-topology__link"
x1={centerX}
y1={centerY}
x2={rp.x}
y2={rp.y}
/>
))}
{/* Local node (center) */}
{localNode && (
<g className="mesh-topology__node" transform={`translate(${centerX}, ${centerY})`}>
<circle
className="mesh-topology__node-circle"
r={NODE_RADIUS}
fill={STATUS_COLORS[localNode.status]}
aria-label={`${localNode.name} (${localNode.status})`}
/>
<text
className="mesh-topology__node-label"
y={NODE_RADIUS + LABEL_OFFSET}
textAnchor="middle"
>
{localNode.name.length > 12 ? `${localNode.name.slice(0, 10)}` : localNode.name}
</text>
<text
className="mesh-topology__node-type"
y={-NODE_RADIUS - 4}
textAnchor="middle"
>
{localNode.type === "local" ? "🏠" : "🌐"}
</text>
</g>
)}
{/* Remote nodes */}
{remotePositions.map((rp) => (
<g key={rp.node.id} className="mesh-topology__node" transform={`translate(${rp.x}, ${rp.y})`}>
<circle
className="mesh-topology__node-circle"
r={NODE_RADIUS}
fill={STATUS_COLORS[rp.node.status]}
aria-label={`${rp.node.name} (${rp.node.status})`}
/>
<text
className="mesh-topology__node-label"
y={NODE_RADIUS + LABEL_OFFSET}
textAnchor="middle"
>
{rp.node.name.length > 12 ? `${rp.node.name.slice(0, 10)}` : rp.node.name}
</text>
<text
className="mesh-topology__node-type"
y={-NODE_RADIUS - 4}
textAnchor="middle"
>
🌐
</text>
</g>
))}
</svg>
{/* Legend */}
<div className="mesh-topology__legend">
<div className="mesh-topology__legend-item">
<span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.online }} />
<span>Online</span>
</div>
<div className="mesh-topology__legend-item">
<span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.offline }} />
<span>Offline</span>
</div>
<div className="mesh-topology__legend-item">
<span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.connecting }} />
<span>Connecting</span>
</div>
<div className="mesh-topology__legend-item">
<span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.error }} />
<span>Error</span>
</div>
</div>
</div>
);
};
}
export const MeshTopology = memo(MeshTopologyInner);

View File

@@ -4,6 +4,7 @@ import { useNodes } from "../hooks/useNodes";
import { useProjects } from "../hooks/useProjects";
import type { NodeInfo, NodeUpdateInput } from "../api";
import { NodeCard } from "./NodeCard";
import { MeshTopology } from "./MeshTopology";
import { AddNodeModal, type AddNodeInput } from "./AddNodeModal";
import { NodeDetailModal } from "./NodeDetailModal";
import type { ToastType } from "../hooks/useToast";
@@ -115,6 +116,14 @@ export function NodesView({ addToast }: NodesViewProps) {
{error && <div className="nodes-view-error">{error}</div>}
{/* Mesh Topology Visualization */}
{!loading && nodes.length > 0 && (
<section className="nodes-view-topology" aria-label="Mesh Topology">
<h3 className="nodes-view-section-title">Mesh Topology</h3>
<MeshTopology nodes={nodes} />
</section>
)}
{loading ? (
<div className="nodes-view-grid">
{Array.from({ length: 4 }).map((_, index) => (

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ConnectNodeModal } from "../ConnectNodeModal";
import type { NodeInfo } from "../../api";
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
id: "node_test",
name: "Test Node",
type: "remote",
status: "online",
maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
describe("ConnectNodeModal", () => {
const defaultProps = {
open: true,
onClose: vi.fn(),
onConnected: vi.fn(),
addToast: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("renders when open", () => {
render(<ConnectNodeModal {...defaultProps} />);
expect(screen.getByLabelText("Connect to Node")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Build Server")).toBeInTheDocument();
expect(screen.getByPlaceholderText("192.168.1.100 or my-server.local")).toBeInTheDocument();
});
it("does not render when closed", () => {
render(<ConnectNodeModal {...defaultProps} open={false} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("validates required name field", async () => {
render(<ConnectNodeModal {...defaultProps} />);
// Fill in host but not name
fireEvent.change(screen.getByPlaceholderText("192.168.1.100 or my-server.local"), {
target: { value: "192.168.1.100" },
});
// Try to submit
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(await screen.findByText("Node name is required")).toBeInTheDocument();
expect(defaultProps.addToast).not.toHaveBeenCalled();
});
it("validates required host field", () => {
render(<ConnectNodeModal {...defaultProps} />);
// Get the host input directly
const hostInput = screen.getByPlaceholderText("192.168.1.100 or my-server.local");
expect(hostInput).toBeInTheDocument();
// Host should be empty initially
expect(hostInput).toHaveValue("");
});
it("validates port range", async () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Server"), {
target: { value: "Test Node" },
});
fireEvent.change(screen.getByPlaceholderText("192.168.1.100 or my-server.local"), {
target: { value: "192.168.1.100" },
});
// Port input is the number input (maxConcurrent is also a number input)
const portInput = screen.getAllByRole("spinbutton")[0];
fireEvent.change(portInput, {
target: { value: "99999" },
});
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(await screen.findByText("Port must be between 1 and 65535")).toBeInTheDocument();
});
it("shows URL preview as host and port are filled", () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("192.168.1.100 or my-server.local"), {
target: { value: "192.168.1.100" },
});
expect(screen.getByText("http://192.168.1.100:3001")).toBeInTheDocument();
});
it("updates URL preview when port changes", () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("192.168.1.100 or my-server.local"), {
target: { value: "my-server.local" },
});
// Port is the first number input
const portInput = screen.getAllByRole("spinbutton")[0];
fireEvent.change(portInput, { target: { value: "8080" } });
expect(screen.getByText("http://my-server.local:8080")).toBeInTheDocument();
});
it("strips protocol from host in URL preview", () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("192.168.1.100 or my-server.local"), {
target: { value: "https://my-server.local" },
});
expect(screen.getByText("http://my-server.local:3001")).toBeInTheDocument();
});
it("calls onConnected with registered node on success", async () => {
const node = makeNode({ name: "Test Node", url: "http://192.168.1.100:3001" });
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(node),
});
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Server"), {
target: { value: "Test Node" },
});
fireEvent.change(screen.getByPlaceholderText("192.168.1.100 or my-server.local"), {
target: { value: "192.168.1.100" },
});
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => {
expect(defaultProps.onConnected).toHaveBeenCalledWith(node);
expect(defaultProps.addToast).toHaveBeenCalledWith('Connected to "Test Node"', "success");
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
it("shows error toast on API failure", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
json: () => Promise.resolve({ error: "Invalid node configuration" }),
});
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Server"), {
target: { value: "Test Node" },
});
fireEvent.change(screen.getByPlaceholderText("192.168.1.100 or my-server.local"), {
target: { value: "invalid-host" },
});
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => {
expect(defaultProps.addToast).toHaveBeenCalledWith("Invalid node configuration", "error");
expect(defaultProps.onClose).not.toHaveBeenCalled();
});
});
it("resets form on close", () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Server"), {
target: { value: "Test Node" },
});
fireEvent.change(screen.getByPlaceholderText("192.168.1.100 or my-server.local"), {
target: { value: "192.168.1.100" },
});
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it("handles Escape key to close", () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(defaultProps.onClose).toHaveBeenCalled();
});
it("disables connect button when host is empty", () => {
render(<ConnectNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Server"), {
target: { value: "Test Node" },
});
const connectButton = screen.getByRole("button", { name: "Connect" });
expect(connectButton).toBeDisabled();
});
it("uses custom onSubmit if provided", async () => {
const customOnSubmit = vi.fn().mockResolvedValue(makeNode({ name: "Custom Node" }));
const node = makeNode({ name: "Custom Node" });
render(<ConnectNodeModal {...defaultProps} onSubmit={customOnSubmit} />);
fireEvent.change(screen.getByPlaceholderText("Build Server"), {
target: { value: "Custom Node" },
});
fireEvent.change(screen.getByPlaceholderText("192.168.1.100 or my-server.local"), {
target: { value: "192.168.1.100" },
});
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => {
expect(customOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "Custom Node",
url: "http://192.168.1.100:3001",
})
);
expect(defaultProps.onConnected).toHaveBeenCalledWith(node);
});
// fetch should not have been called
expect(mockFetch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MeshTopology } from "../MeshTopology";
import type { NodeInfo } from "../../api";
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
id: "node_test",
name: "Test Node",
type: "local",
status: "online",
maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
describe("MeshTopology", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders empty state when no nodes provided", () => {
render(<MeshTopology nodes={[]} />);
expect(screen.getByText("No nodes to display")).toBeInTheDocument();
});
it("renders empty state when empty array provided", () => {
render(<MeshTopology nodes={[]} />);
const svg = document.querySelector(".mesh-topology__svg");
expect(svg).not.toBeInTheDocument();
});
it("renders local node with correct status color", () => {
const nodes = [makeNode({ id: "local", name: "Local", type: "local", status: "online" })];
render(<MeshTopology nodes={nodes} />);
const circles = document.querySelectorAll(".mesh-topology__node-circle");
expect(circles).toHaveLength(1);
expect(circles[0]).toHaveAttribute("fill", expect.stringContaining("var(--success"));
});
it("renders remote nodes in circular arrangement", () => {
const nodes = [
makeNode({ id: "local", name: "Local", type: "local", status: "online" }),
makeNode({ id: "remote1", name: "Remote 1", type: "remote", status: "online" }),
makeNode({ id: "remote2", name: "Remote 2", type: "remote", status: "offline" }),
];
render(<MeshTopology nodes={nodes} />);
const circles = document.querySelectorAll(".mesh-topology__node-circle");
expect(circles).toHaveLength(3); // 1 local + 2 remote
});
it("renders link lines between local and remote nodes", () => {
const nodes = [
makeNode({ id: "local", name: "Local", type: "local" }),
makeNode({ id: "remote", name: "Remote", type: "remote" }),
];
render(<MeshTopology nodes={nodes} />);
const links = document.querySelectorAll(".mesh-topology__link");
expect(links).toHaveLength(1); // One line from local to remote
});
it("renders legend with status colors", () => {
render(<MeshTopology nodes={[makeNode()]} />);
const legend = document.querySelector(".mesh-topology__legend");
expect(legend).toBeInTheDocument();
expect(screen.getByText("Online")).toBeInTheDocument();
expect(screen.getByText("Offline")).toBeInTheDocument();
expect(screen.getByText("Connecting")).toBeInTheDocument();
expect(screen.getByText("Error")).toBeInTheDocument();
});
it("applies custom className", () => {
const { container } = render(<MeshTopology nodes={[]} className="custom-class" />);
expect(container.firstChild).toHaveClass("custom-class");
expect(container.firstChild).toHaveClass("mesh-topology");
});
it("renders node labels truncated at 12 characters", () => {
const nodes = [makeNode({ name: "This is a very long node name that should be truncated" })];
render(<MeshTopology nodes={nodes} />);
// The label should contain truncated text
const label = document.querySelector(".mesh-topology__node-label");
expect(label).toBeInTheDocument();
});
it("renders SVG with correct viewBox", () => {
const nodes = [
makeNode({ id: "local", name: "Local", type: "local" }),
makeNode({ id: "remote1", name: "Remote 1", type: "remote" }),
makeNode({ id: "remote2", name: "Remote 2", type: "remote" }),
makeNode({ id: "remote3", name: "Remote 3", type: "remote" }),
makeNode({ id: "remote4", name: "Remote 4", type: "remote" }),
];
render(<MeshTopology nodes={nodes} />);
const svg = document.querySelector(".mesh-topology__svg");
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute("viewBox");
});
it("renders correct status color for offline nodes", () => {
const nodes = [makeNode({ id: "offline", name: "Offline Node", status: "offline" })];
render(<MeshTopology nodes={nodes} />);
const circles = document.querySelectorAll(".mesh-topology__node-circle");
expect(circles).toHaveLength(1);
expect(circles[0]).toHaveAttribute("fill", expect.stringContaining("var(--text-dim"));
});
it("renders correct status color for error nodes", () => {
const nodes = [makeNode({ id: "error", name: "Error Node", status: "error" })];
render(<MeshTopology nodes={nodes} />);
const circles = document.querySelectorAll(".mesh-topology__node-circle");
expect(circles).toHaveLength(1);
expect(circles[0]).toHaveAttribute("fill", expect.stringContaining("var(--color-error"));
});
it("renders correct status color for connecting nodes", () => {
const nodes = [makeNode({ id: "connecting", name: "Connecting Node", status: "connecting" })];
render(<MeshTopology nodes={nodes} />);
const circles = document.querySelectorAll(".mesh-topology__node-circle");
expect(circles).toHaveLength(1);
expect(circles[0]).toHaveAttribute("fill", expect.stringContaining("var(--triage"));
});
});

View File

@@ -89,13 +89,18 @@ describe("NodesView", () => {
render(<NodesView addToast={vi.fn()} />);
expect(screen.getByText("Alpha")).toBeDefined();
expect(screen.getByText("Beta")).toBeDefined();
// Check node cards are rendered - use the node card class to find elements
const nodeCards = document.querySelectorAll(".node-card");
expect(nodeCards).toHaveLength(2);
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");
// Check mesh topology is rendered
const svg = document.querySelector(".mesh-topology__svg");
expect(svg).toBeInTheDocument();
});
it("renders empty state when there are no nodes", () => {
@@ -105,6 +110,10 @@ describe("NodesView", () => {
expect(screen.getByText("No nodes are registered yet.")).toBeDefined();
expect(screen.getByText("Add First Node")).toBeDefined();
// Mesh topology should not be rendered when there are no nodes
const svg = document.querySelector(".mesh-topology__svg");
expect(svg).not.toBeInTheDocument();
});
it("opens Add Node modal when Add Node button is clicked", () => {
@@ -128,12 +137,15 @@ describe("NodesView", () => {
});
mockUseNodes.mockReturnValue(makeUseNodesResult({
nodes: [makeNode({ id: "node-1", name: "Alpha Node" })],
nodes: [makeNode({ id: "node-1", name: "Detail Node" })],
}));
render(<NodesView addToast={vi.fn()} />);
fireEvent.click(screen.getByText("Alpha Node"));
expect(screen.getByRole("dialog", { name: "Node details for Alpha Node" })).toBeDefined();
// Click on the node card (not the topology node)
const nodeCard = document.querySelector(".node-card");
expect(nodeCard).toBeInTheDocument();
fireEvent.click(nodeCard!);
expect(screen.getByRole("dialog", { name: "Node details for Detail Node" })).toBeDefined();
});
});