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 d8619ae298
commit 0304df348f
10 changed files with 1108 additions and 220 deletions

View File

@@ -23,6 +23,8 @@ import type {
ParticipantType,
NodeConfig,
NodeStatus,
NodeMeshState,
SystemMetrics,
DiscoveryConfig,
MissionEvent,
MissionHealth,
@@ -2699,37 +2701,13 @@ export async function checkNodeHealth(id: string): Promise<NodeHealthCheckResult
}
/** Fetch runtime metrics for a node */
export async function fetchNodeMetrics(id: string): Promise<NodeMetrics> {
const metrics = await api<Partial<NodeMetrics> & { activeTasks?: number }>(`/nodes/${encodeURIComponent(id)}/metrics`);
return {
nodeId: metrics.nodeId ?? id,
activeTaskCount: metrics.activeTaskCount ?? metrics.activeTasks ?? 0,
inFlightAgentCount: metrics.inFlightAgentCount ?? 0,
uptimeMs: metrics.uptimeMs ?? 0,
lastActivityAt: metrics.lastActivityAt,
};
export async function fetchNodeMetrics(id: string): Promise<SystemMetrics | null> {
return api<SystemMetrics | null>(`/nodes/${encodeURIComponent(id)}/metrics`);
}
/** Mesh topology connection to another node */
export interface MeshPeerConnection {
peerId: string;
peerName: string;
status: NodeStatus;
}
/** Full mesh topology state with peer connections */
export interface MeshState {
id: string;
name: string;
type: "local" | "remote";
status: NodeStatus;
url: string | null;
connections: MeshPeerConnection[];
}
/** Fetch the full mesh topology state */
export async function fetchMeshState(): Promise<MeshState[]> {
return api<MeshState[]>("/mesh/state");
/** Fetch full mesh topology state (all nodes with their metrics and known peers) */
export async function fetchMeshState(): Promise<NodeMeshState[]> {
return api<NodeMeshState[]>("/mesh/state");
}
/** Browse directory entries for the directory picker */

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();
});
});

View File

@@ -28417,3 +28417,165 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
.reflection-empty p {
font-size: 0.875rem;
}
/* ── Mesh Topology ──────────────────────────────────────────────────────────── */
.mesh-topology {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.mesh-topology__svg {
width: 100%;
max-width: 400px;
height: auto;
aspect-ratio: 1;
}
.mesh-topology__node-circle {
stroke: var(--border);
stroke-width: 2;
transition: fill var(--transition-fast);
}
.mesh-topology__node-label {
font-size: 11px;
fill: var(--text);
font-weight: 500;
}
.mesh-topology__node-type {
font-size: 14px;
}
.mesh-topology__link {
stroke: var(--border);
stroke-width: 1.5;
stroke-dasharray: 4, 4;
opacity: 0.6;
}
.mesh-topology__peer-line {
stroke: var(--accent);
stroke-width: 1;
opacity: 0.3;
stroke-dasharray: 2, 2;
}
.mesh-topology__legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-md);
font-size: 12px;
color: var(--text-muted);
}
.mesh-topology__legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.mesh-topology__legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid var(--border);
}
.mesh-topology--empty {
min-height: 200px;
justify-content: center;
}
.mesh-topology__empty-state {
text-align: center;
color: var(--text-muted);
font-size: 14px;
}
/* ── Connect Node Modal ──────────────────────────────────────────────────────── */
.connect-node-modal {
width: min(480px, calc(100vw - 32px));
}
.connect-node-form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.connect-node-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.connect-node-field > span {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.connect-node-field input {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: 14px;
}
.connect-node-field input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
}
.connect-node-url-preview {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: color-mix(in srgb, var(--surface) 50%, var(--bg));
border-radius: var(--radius-sm);
font-size: 12px;
}
.connect-node-url-preview-label {
color: var(--text-muted);
}
.connect-node-url-preview code {
font-family: var(--font-mono);
color: var(--text);
}
.connect-node-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--border);
}
/* ── Nodes View ─────────────────────────────────────────────────────────────── */
.nodes-view-topology {
margin-bottom: var(--space-md);
}
.nodes-view-section-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
margin: 0 0 var(--space-sm);
}

View File

@@ -15,6 +15,7 @@ const mockCheckNodeHealth = vi.fn();
const mockUpdateProject = vi.fn();
const mockAssignProjectToNode = vi.fn();
const mockUnassignProjectFromNode = vi.fn();
const mockGetMeshState = vi.fn();
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
@@ -32,6 +33,7 @@ vi.mock("@fusion/core", async () => {
updateProject: mockUpdateProject,
assignProjectToNode: mockAssignProjectToNode,
unassignProjectFromNode: mockUnassignProjectFromNode,
getMeshState: mockGetMeshState,
})),
};
});
@@ -121,6 +123,16 @@ describe("Node routes", () => {
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockGetMeshState.mockResolvedValue({
nodeId: "node_local",
nodeName: "local-node",
nodeUrl: undefined,
status: "online",
metrics: null,
lastSeen: "2026-01-01T00:00:00.000Z",
connectedAt: "2026-01-01T00:00:00.000Z",
knownPeers: [],
});
});
it("GET /api/nodes returns an empty array when no nodes are registered", async () => {
@@ -270,8 +282,8 @@ describe("Node routes", () => {
const res = await request(app, "DELETE", "/api/nodes/node_1");
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(res.status).toBe(204);
expect(res.body).toBeNull();
expect(mockUnregisterNode).toHaveBeenCalledWith("node_1");
});
@@ -300,26 +312,71 @@ describe("Node routes", () => {
expect(res.status).toBe(404);
});
it("GET /api/nodes/:id/metrics returns stub metrics for local node", async () => {
mockGetNode.mockResolvedValue(makeNode({ id: "node_1", type: "local", maxConcurrent: 8 }));
it("GET /api/mesh/state returns mesh topology state", async () => {
const localMeshState = {
nodeId: "node_local",
nodeName: "local",
nodeUrl: undefined,
status: "online" as const,
metrics: null,
lastSeen: "2026-01-01T00:00:00.000Z",
connectedAt: "2026-01-01T00:00:00.000Z",
knownPeers: [],
};
const remoteMeshState = {
nodeId: "node_remote",
nodeName: "remote",
nodeUrl: "http://remote:3001",
status: "online" as const,
metrics: { cpuUsage: 30, memoryUsed: 2e9, memoryTotal: 8e9, storageUsed: 100e9, storageTotal: 500e9, uptime: 3600000, reportedAt: "2026-01-01T00:00:00.000Z" },
lastSeen: "2026-01-01T00:00:00.000Z",
connectedAt: "2026-01-01T00:00:00.000Z",
knownPeers: [{ id: "peer_1", nodeId: "node_remote", peerNodeId: "node_local", name: "local", url: "http://localhost:3001", status: "online" as const, lastSeen: "2026-01-01T00:00:00.000Z", connectedAt: "2026-01-01T00:00:00.000Z" }],
};
mockListNodes.mockResolvedValue([
makeNode({ id: "node_local", name: "local", type: "local" }),
makeNode({ id: "node_remote", name: "remote", type: "remote", url: "http://remote:3001" }),
]);
mockGetMeshState
.mockResolvedValueOnce(localMeshState)
.mockResolvedValueOnce(remoteMeshState);
const res = await request(app, "GET", "/api/mesh/state");
expect(res.status).toBe(200);
expect((res.body as any[])).toHaveLength(2);
expect((res.body as any[])[0].nodeId).toBe("node_local");
expect((res.body as any[])[1].nodeId).toBe("node_remote");
});
it("GET /api/nodes/:id/metrics returns systemMetrics from node", async () => {
const systemMetrics = {
cpuUsage: 45.5,
memoryUsed: 4294967296,
memoryTotal: 8589934592,
storageUsed: 107374182400,
storageTotal: 536870912000,
uptime: 86400000,
reportedAt: "2026-01-01T00:00:00.000Z",
};
mockGetNode.mockResolvedValue(
makeNode({ id: "node_1", type: "local", maxConcurrent: 8, systemMetrics })
);
const res = await request(app, "GET", "/api/nodes/node_1/metrics");
expect(res.status).toBe(200);
expect(res.body).toEqual({
status: "online",
activeTasks: 0,
maxConcurrent: 8,
});
expect(res.body).toEqual(systemMetrics);
});
it("GET /api/nodes/:id/metrics returns 501 for remote nodes", async () => {
it("GET /api/nodes/:id/metrics returns null when no metrics available", async () => {
mockGetNode.mockResolvedValue(makeNode({ id: "node_2", type: "remote" }));
const res = await request(app, "GET", "/api/nodes/node_2/metrics");
expect(res.status).toBe(501);
expect(res.body).toEqual({ error: "Remote node metrics not yet implemented" });
expect(res.status).toBe(200);
expect(res.body).toBeNull();
});
it("PATCH /api/projects/:id assigns project to node when nodeId is provided", async () => {

View File

@@ -10421,11 +10421,10 @@ Output ONLY the prompt text (no markdown, no explanations).`;
throw badRequest("name is required and must be a non-empty string");
}
if (type !== "local" && type !== "remote") {
throw badRequest("type must be 'local' or 'remote'");
}
// Default to "remote" for backward compatibility with frontend API calls
const nodeType = type === "local" || type === "remote" ? type : "remote";
if (type === "remote" && (!url || typeof url !== "string" || !url.trim())) {
if (nodeType === "remote" && (!url || typeof url !== "string" || !url.trim())) {
throw badRequest("url is required for remote nodes");
}
@@ -10449,7 +10448,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
const node = await central.registerNode({
name: name.trim(),
type,
type: nodeType,
url: typeof url === "string" ? url.trim() : undefined,
apiKey: typeof apiKey === "string" ? apiKey : undefined,
maxConcurrent,
@@ -10545,7 +10544,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
await central.unregisterNode(req.params.id);
await central.close();
res.json({ success: true });
res.status(204).end();
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
@@ -10579,7 +10578,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
/**
* GET /api/nodes/:id/metrics
* Get node runtime metrics.
* Get node runtime metrics (SystemMetrics from node's systemMetrics field).
*/
router.get("/nodes/:id/metrics", async (req, res) => {
try {
@@ -10594,16 +10593,34 @@ Output ONLY the prompt text (no markdown, no explanations).`;
throw notFound("Node not found");
}
if (node.type === "local") {
res.json({
status: "online",
activeTasks: 0,
maxConcurrent: node.maxConcurrent,
});
return;
// Return the systemMetrics field which contains SystemMetrics or null
res.json(node.systemMetrics ?? null);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
throw new ApiError(501, "Remote node metrics not yet implemented");
/**
* GET /api/mesh/state
* Get full mesh topology state (all nodes with their metrics and known peers).
*/
router.get("/mesh/state", async (_req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const nodes = await central.listNodes();
const meshStates = await Promise.all(
nodes.map((node) => central.getMeshState(node.id))
);
await central.close();
res.json(meshStates);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;