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:
266
packages/dashboard/app/components/ConnectNodeModal.tsx
Normal file
266
packages/dashboard/app/components/ConnectNodeModal.tsx
Normal 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">
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user