feat(FN-1081): add dashboard nodes management workflow

- Add node API typings and client methods plus a polling useNodes hook with visibility-triggered refresh
- Introduce NodesView with NodeCard, AddNodeModal, and NodeDetailModal to register, inspect, update, health-check, and remove nodes
- Wire the Nodes surface into the app shell/header and add project node assignment UI with project card node badges
- Add unit coverage for useNodes, NodesView, NodeCard, Header, and ProjectCard behavior, plus node-specific dashboard styling
This commit is contained in:
gsxdsm
2026-04-07 23:30:32 -07:00
parent 74cae4f870
commit 075402c11d
17 changed files with 2218 additions and 32 deletions

View File

@@ -0,0 +1,212 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ToastType } from "../hooks/useToast";
export interface AddNodeInput {
name: string;
type: "local" | "remote";
url?: string;
apiKey?: string;
maxConcurrent: number;
}
interface AddNodeModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (input: AddNodeInput) => Promise<void>;
addToast: (message: string, type?: ToastType) => void;
}
interface FormErrors {
name?: string;
url?: string;
maxConcurrent?: string;
}
const MAX_CONCURRENT_MIN = 1;
const MAX_CONCURRENT_MAX = 10;
function validateInput(input: AddNodeInput): FormErrors {
const errors: FormErrors = {};
if (!input.name.trim()) {
errors.name = "Name is required";
}
if (input.type === "remote" && !input.url?.trim()) {
errors.url = "URL is required for remote nodes";
}
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;
}
export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeModalProps) {
const [name, setName] = useState("");
const [type, setType] = useState<"local" | "remote">("local");
const [url, setUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState(2);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = useCallback(() => {
setName("");
setType("local");
setUrl("");
setApiKey("");
setMaxConcurrent(2);
setErrors({});
setIsSubmitting(false);
}, []);
const closeModal = useCallback(() => {
if (isSubmitting) return;
resetForm();
onClose();
}, [isSubmitting, onClose, resetForm]);
useEffect(() => {
if (!isOpen) {
resetForm();
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
closeModal();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [closeModal, isOpen, resetForm]);
const input = useMemo<AddNodeInput>(() => ({
name: name.trim(),
type,
url: type === "remote" ? url.trim() || undefined : undefined,
apiKey: type === "remote" ? apiKey || undefined : undefined,
maxConcurrent,
}), [apiKey, maxConcurrent, name, type, url]);
const handleSubmit = useCallback(async () => {
if (isSubmitting) return;
const validationErrors = validateInput(input);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
return;
}
setIsSubmitting(true);
try {
await onSubmit(input);
addToast(`Node \"${input.name}\" registered`, "success");
closeModal();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to register node";
addToast(message, "error");
} finally {
setIsSubmitting(false);
}
}, [addToast, closeModal, input, isSubmitting, onSubmit]);
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={closeModal}>
<div className="modal modal-md add-node-modal" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label="Add Node">
<div className="modal-header">
<h3>Add Node</h3>
<button className="modal-close" onClick={closeModal} disabled={isSubmitting} aria-label="Close add node modal">
&times;
</button>
</div>
<div className="modal-body add-node-modal__body">
<label className="add-node-modal__field">
<span>Name</span>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Build Machine"
disabled={isSubmitting}
aria-invalid={Boolean(errors.name)}
/>
{errors.name && <span className="add-node-modal__error">{errors.name}</span>}
</label>
<label className="add-node-modal__field">
<span>Type</span>
<select
value={type}
onChange={(event) => setType(event.target.value as "local" | "remote")}
disabled={isSubmitting}
>
<option value="local">Local</option>
<option value="remote">Remote</option>
</select>
</label>
{type === "remote" && (
<>
<label className="add-node-modal__field">
<span>URL</span>
<input
type="text"
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://node.example.com"
disabled={isSubmitting}
aria-invalid={Boolean(errors.url)}
/>
{errors.url && <span className="add-node-modal__error">{errors.url}</span>}
</label>
<label className="add-node-modal__field">
<span>API Key</span>
<input
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="Optional"
disabled={isSubmitting}
/>
</label>
</>
)}
<label className="add-node-modal__field">
<span>Max Concurrent</span>
<input
type="number"
min={MAX_CONCURRENT_MIN}
max={MAX_CONCURRENT_MAX}
value={maxConcurrent}
onChange={(event) => setMaxConcurrent(Number(event.target.value))}
disabled={isSubmitting}
aria-invalid={Boolean(errors.maxConcurrent)}
/>
{errors.maxConcurrent && <span className="add-node-modal__error">{errors.maxConcurrent}</span>}
</label>
</div>
<div className="modal-actions">
<button className="btn btn-sm" onClick={closeModal} disabled={isSubmitting}>Cancel</button>
<button className="btn btn-primary btn-sm" onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? "Adding..." : "Add Node"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -644,6 +644,28 @@ describe("Header", () => {
});
});
describe("nodes button", () => {
it("renders Nodes button on desktop when handler is provided", () => {
renderHeader({ onOpenNodes: vi.fn() }, "desktop");
expect(screen.getByTitle("Nodes")).toBeDefined();
});
it("calls onOpenNodes when Nodes button is clicked", () => {
const onOpenNodes = vi.fn();
renderHeader({ onOpenNodes }, "desktop");
fireEvent.click(screen.getByTitle("Nodes"));
expect(onOpenNodes).toHaveBeenCalled();
});
it("shows Nodes action in mobile overflow menu", () => {
const onOpenNodes = vi.fn();
renderHeader({ onOpenNodes }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-nodes-btn"));
expect(onOpenNodes).toHaveBeenCalled();
});
});
describe("search functionality", () => {
it("does not render search input when onSearchChange is not provided", () => {
renderHeader({ view: "board" });

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail } from "lucide-react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail } from "lucide-react";
import type { ProjectInfo } from "../api";
import { fetchScripts } from "../api";
import { ProjectSelector } from "./ProjectSelector";
@@ -36,6 +36,7 @@ export interface HeaderProps {
mailboxUnreadCount?: number;
onOpenSchedules?: () => void;
onOpenGitManager?: () => void;
onOpenNodes?: () => void;
onOpenWorkflowSteps?: () => void;
onOpenMissions?: () => void;
onOpenScripts?: () => void;
@@ -111,6 +112,7 @@ export function Header({
mailboxUnreadCount = 0,
onOpenSchedules,
onOpenGitManager,
onOpenNodes,
onOpenWorkflowSteps,
onOpenMissions,
onOpenScripts,
@@ -516,6 +518,18 @@ export function Header({
</button>
)}
{/* Nodes button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenNodes && (
<button
className="btn-icon"
onClick={onOpenNodes}
title="Nodes"
data-testid="nodes-btn"
>
<Server size={16} />
</button>
)}
{/* Workflow Steps - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenWorkflowSteps && (
<button
@@ -648,6 +662,18 @@ export function Header({
<span>Git Manager</span>
</button>
)}
{/* Nodes - in overflow on mobile */}
{onOpenNodes && (
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenNodes)}
role="menuitem"
data-testid="overflow-nodes-btn"
>
<Server size={16} />
<span>Nodes</span>
</button>
)}
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenGitHubImport)}

View File

@@ -0,0 +1,186 @@
import { memo, useCallback, useMemo, useState } from "react";
import { Activity, Server, Settings, Trash2 } from "lucide-react";
import type { NodeInfo, ProjectInfo } from "../api";
export interface NodeCardProps {
node: NodeInfo;
projects: ProjectInfo[];
onHealthCheck: (id: string) => void;
onEdit: (node: NodeInfo) => void;
onRemove: (id: string) => void;
isLoading?: boolean;
}
const STATUS_CONFIG: Record<NodeInfo["status"], { label: string; color: string; className: string }> = {
online: { label: "Online", color: "var(--success)", className: "node-card__status--online" },
offline: { label: "Offline", color: "var(--color-error)", className: "node-card__status--offline" },
connecting: { label: "Connecting", color: "var(--warning)", className: "node-card__status--connecting" },
error: { label: "Error", color: "var(--color-error)", className: "node-card__status--error" },
};
function truncateUrl(url: string, maxLength: number = 42): string {
if (url.length <= maxLength) return url;
return `${url.slice(0, maxLength - 3)}...`;
}
function getAssignedProjectCount(projects: ProjectInfo[], nodeId: string): number {
return projects.filter((project) => project.nodeId === nodeId).length;
}
function areNodeCardPropsEqual(previous: NodeCardProps, next: NodeCardProps): boolean {
const prevNode = previous.node;
const nextNode = next.node;
if (prevNode.id !== nextNode.id) return false;
if (prevNode.name !== nextNode.name) return false;
if (prevNode.type !== nextNode.type) return false;
if (prevNode.url !== nextNode.url) return false;
if (prevNode.status !== nextNode.status) return false;
if (prevNode.maxConcurrent !== nextNode.maxConcurrent) return false;
if (prevNode.updatedAt !== nextNode.updatedAt) return false;
if (previous.isLoading !== next.isLoading) return false;
const previousCount = getAssignedProjectCount(previous.projects, prevNode.id);
const nextCount = getAssignedProjectCount(next.projects, nextNode.id);
return previousCount === nextCount;
}
function NodeCardInner({
node,
projects,
onHealthCheck,
onEdit,
onRemove,
isLoading = false,
}: NodeCardProps) {
const [removeArmed, setRemoveArmed] = useState(false);
const statusConfig = STATUS_CONFIG[node.status];
const assignedProjectCount = useMemo(() => {
return getAssignedProjectCount(projects, node.id);
}, [projects, node.id]);
const handleOpenDetails = useCallback(() => {
onEdit(node);
}, [onEdit, node]);
const handleHealthCheck = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
onHealthCheck(node.id);
}, [onHealthCheck, node.id]);
const handleEdit = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
onEdit(node);
}, [onEdit, node]);
const handleRemove = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
if (!removeArmed) {
setRemoveArmed(true);
return;
}
onRemove(node.id);
setRemoveArmed(false);
}, [removeArmed, onRemove, node.id]);
const handleCardKeyDown = useCallback((event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onEdit(node);
}
}, [onEdit, node]);
return (
<article
className={`node-card ${isLoading ? "node-card--loading" : ""}`}
data-node-id={node.id}
role="button"
tabIndex={0}
onClick={handleOpenDetails}
onKeyDown={handleCardKeyDown}
>
<header className="node-card__header">
<div className="node-card__title-wrap">
<div className="node-card__icon">
<Server size={18} />
</div>
<div>
<h3 className="node-card__name" title={node.name}>{node.name}</h3>
<div className="node-card__meta-row">
<span className="node-card__type-badge">{node.type === "local" ? "Local" : "Remote"}</span>
<span
className={`node-card__status ${statusConfig.className}`}
style={{ color: statusConfig.color }}
data-status={node.status}
>
<span className="node-card__status-indicator" style={{ backgroundColor: statusConfig.color }} aria-hidden />
{statusConfig.label}
</span>
</div>
</div>
</div>
</header>
<div className="node-card__body">
{node.type === "remote" && node.url && (
<div className="node-card__url" title={node.url}>
{truncateUrl(node.url)}
</div>
)}
<div className="node-card__metrics">
<div className="node-card__metric">
<span className="node-card__metric-label">Projects</span>
<span className="node-card__metric-value">{assignedProjectCount}</span>
</div>
<div className="node-card__metric">
<span className="node-card__metric-label">Concurrency</span>
<span className="node-card__metric-value">{node.maxConcurrent}</span>
</div>
</div>
</div>
<footer className="node-card__actions">
<button
className="node-card__action"
type="button"
onClick={handleHealthCheck}
disabled={isLoading}
aria-label="Run node health check"
title="Health Check"
>
<Activity size={14} />
<span>Health</span>
</button>
<button
className="node-card__action"
type="button"
onClick={handleEdit}
disabled={isLoading}
aria-label="Edit node"
title="Edit"
>
<Settings size={14} />
<span>Edit</span>
</button>
<button
className={`node-card__action node-card__action--remove ${removeArmed ? "is-armed" : ""}`}
type="button"
onClick={handleRemove}
disabled={isLoading}
aria-label={removeArmed ? "Confirm remove node" : "Remove node"}
title={removeArmed ? "Confirm remove" : "Remove"}
>
<Trash2 size={14} />
<span>{removeArmed ? "Confirm" : "Remove"}</span>
</button>
</footer>
</article>
);
}
export const NodeCard = memo(NodeCardInner, areNodeCardPropsEqual);

View File

@@ -0,0 +1,281 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Activity, Pencil, Save, X } from "lucide-react";
import type { NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
import type { ToastType } from "../hooks/useToast";
interface NodeDetailModalProps {
isOpen: boolean;
onClose: () => void;
node: NodeInfo | null;
projects: ProjectInfo[];
onUpdate: (id: string, updates: NodeUpdateInput) => Promise<void>;
onHealthCheck: (id: string) => Promise<void>;
addToast: (message: string, type?: ToastType) => void;
}
function formatTimestamp(value?: string): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString();
}
export function NodeDetailModal({
isOpen,
onClose,
node,
projects,
onUpdate,
onHealthCheck,
addToast,
}: NodeDetailModalProps) {
const [editMode, setEditMode] = useState(false);
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState(2);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!node || !isOpen) {
setEditMode(false);
return;
}
setName(node.name);
setUrl(node.url ?? "");
setApiKey(node.apiKey ?? "");
setMaxConcurrent(node.maxConcurrent);
setEditMode(false);
}, [isOpen, node]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
const assignedProjects = useMemo(() => {
if (!node) return [];
return projects.filter((project) => project.nodeId === node.id);
}, [node, projects]);
const handleHealthCheck = useCallback(async () => {
if (!node) return;
try {
await onHealthCheck(node.id);
addToast(`Health check completed for ${node.name}`, "success");
} catch (error) {
const message = error instanceof Error ? error.message : "Health check failed";
addToast(message, "error");
}
}, [addToast, node, onHealthCheck]);
const handleSave = useCallback(async () => {
if (!node || isSaving) return;
const trimmedName = name.trim();
if (!trimmedName) {
addToast("Name is required", "error");
return;
}
if (node.type === "remote" && !url.trim()) {
addToast("URL is required for remote nodes", "error");
return;
}
if (!Number.isFinite(maxConcurrent) || maxConcurrent < 1) {
addToast("Concurrency must be at least 1", "error");
return;
}
setIsSaving(true);
try {
await onUpdate(node.id, {
name: trimmedName,
url: node.type === "remote" ? url.trim() || undefined : undefined,
apiKey: node.type === "remote" ? apiKey || undefined : undefined,
maxConcurrent,
});
addToast(`Updated ${trimmedName}`, "success");
setEditMode(false);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to update node";
addToast(message, "error");
} finally {
setIsSaving(false);
}
}, [addToast, apiKey, isSaving, maxConcurrent, name, node, onUpdate, url]);
const handleCancelEdit = useCallback(() => {
if (!node) return;
setName(node.name);
setUrl(node.url ?? "");
setApiKey(node.apiKey ?? "");
setMaxConcurrent(node.maxConcurrent);
setEditMode(false);
}, [node]);
if (!isOpen || !node) return null;
return (
<div className="modal-overlay open" onClick={onClose}>
<div
className="modal modal-lg node-detail-modal"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={`Node details for ${node.name}`}
>
<div className="modal-header">
<h3>Node Details</h3>
<button className="modal-close" onClick={onClose} aria-label="Close node detail modal">&times;</button>
</div>
<div className="modal-body node-detail-modal__body">
<section className="node-detail-modal__section">
<div className="node-detail-modal__section-header">
<h4>Overview</h4>
{!editMode && (
<button className="btn btn-sm" onClick={() => setEditMode(true)}>
<Pencil size={14} />
Edit
</button>
)}
</div>
<div className="node-detail-modal__grid">
<label className="node-detail-modal__field">
<span>Name</span>
{editMode ? (
<input value={name} onChange={(event) => setName(event.target.value)} disabled={isSaving} />
) : (
<strong>{node.name}</strong>
)}
</label>
<div className="node-detail-modal__field">
<span>Type</span>
<strong>{node.type === "local" ? "Local" : "Remote"}</strong>
</div>
<div className="node-detail-modal__field">
<span>Status</span>
<strong>{node.status}</strong>
</div>
<label className="node-detail-modal__field">
<span>Max Concurrent</span>
{editMode ? (
<input
type="number"
min={1}
max={10}
value={maxConcurrent}
onChange={(event) => setMaxConcurrent(Number(event.target.value))}
disabled={isSaving}
/>
) : (
<strong>{node.maxConcurrent}</strong>
)}
</label>
{node.type === "remote" && (
<>
<label className="node-detail-modal__field node-detail-modal__field--full">
<span>URL</span>
{editMode ? (
<input value={url} onChange={(event) => setUrl(event.target.value)} disabled={isSaving} />
) : (
<strong>{node.url ?? "—"}</strong>
)}
</label>
<label className="node-detail-modal__field node-detail-modal__field--full">
<span>API Key</span>
{editMode ? (
<input
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="Leave blank to keep unchanged"
disabled={isSaving}
/>
) : (
<strong>{node.apiKey ? "••••••••" : "Not configured"}</strong>
)}
</label>
</>
)}
<div className="node-detail-modal__field">
<span>Created</span>
<strong>{formatTimestamp(node.createdAt)}</strong>
</div>
<div className="node-detail-modal__field">
<span>Updated</span>
<strong>{formatTimestamp(node.updatedAt)}</strong>
</div>
</div>
{editMode && (
<div className="node-detail-modal__edit-actions">
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={isSaving}>
<Save size={14} />
{isSaving ? "Saving..." : "Save"}
</button>
<button className="btn btn-sm" onClick={handleCancelEdit} disabled={isSaving}>
<X size={14} />
Cancel
</button>
</div>
)}
</section>
<section className="node-detail-modal__section">
<h4>Assigned Projects ({assignedProjects.length})</h4>
{assignedProjects.length === 0 ? (
<p className="node-detail-modal__empty">No projects are assigned to this node.</p>
) : (
<ul className="node-detail-modal__project-list">
{assignedProjects.map((project) => (
<li key={project.id} className="node-detail-modal__project-item">
<span>{project.name}</span>
<code>{project.id}</code>
</li>
))}
</ul>
)}
</section>
<section className="node-detail-modal__section">
<h4>Health</h4>
<div className="node-detail-modal__health-row">
<span>Status: <strong>{node.status}</strong></span>
<span>Last check: <strong>{formatTimestamp(node.updatedAt)}</strong></span>
</div>
</section>
</div>
<div className="modal-actions node-detail-modal__actions">
<button className="btn btn-sm" onClick={handleHealthCheck}>
<Activity size={14} />
Health Check
</button>
<button className="btn btn-sm" onClick={onClose}>Close</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Plus, Server, Wifi, WifiOff, Globe, RefreshCw } from "lucide-react";
import { useNodes } from "../hooks/useNodes";
import { useProjects } from "../hooks/useProjects";
import type { NodeInfo, NodeUpdateInput } from "../api";
import { NodeCard } from "./NodeCard";
import { AddNodeModal, type AddNodeInput } from "./AddNodeModal";
import { NodeDetailModal } from "./NodeDetailModal";
import type { ToastType } from "../hooks/useToast";
interface NodesViewProps {
addToast: (message: string, type?: ToastType) => void;
}
export function NodesView({ addToast }: NodesViewProps) {
const { nodes, loading, error, refresh, register, update, unregister, healthCheck } = useNodes();
const { projects } = useProjects();
const [addModalOpen, setAddModalOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null);
useEffect(() => {
if (!selectedNode) return;
const latest = nodes.find((node) => node.id === selectedNode.id) ?? null;
setSelectedNode(latest);
}, [nodes, selectedNode]);
const stats = useMemo(() => {
const total = nodes.length;
const online = nodes.filter((node) => node.status === "online").length;
const offline = nodes.filter((node) => node.status === "offline" || node.status === "error").length;
const remote = nodes.filter((node) => node.type === "remote").length;
return { total, online, offline, remote };
}, [nodes]);
const handleRegister = useCallback(async (input: AddNodeInput) => {
await register(input);
}, [register]);
const handleRefresh = useCallback(async () => {
try {
await refresh();
} catch {
addToast("Failed to refresh nodes", "error");
}
}, [addToast, refresh]);
const handleHealthCheck = useCallback(async (id: string) => {
try {
await healthCheck(id);
addToast("Node health check complete", "success");
} catch (err) {
const message = err instanceof Error ? err.message : "Health check failed";
addToast(message, "error");
}
}, [addToast, healthCheck]);
const handleUnregister = useCallback(async (id: string) => {
try {
await unregister(id);
addToast("Node removed", "success");
if (selectedNode?.id === id) {
setSelectedNode(null);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to remove node";
addToast(message, "error");
}
}, [addToast, selectedNode?.id, unregister]);
const handleUpdate = useCallback(async (id: string, updates: NodeUpdateInput) => {
await update(id, updates);
}, [update]);
return (
<div className="nodes-view" data-testid="nodes-view">
<div className="nodes-view-header">
<div className="nodes-view-title">
<h2>
<Server size={20} />
Nodes
</h2>
<span className="nodes-view-count">{nodes.length} registered</span>
</div>
<div className="nodes-view-actions">
<button className="btn btn-sm" onClick={() => void handleRefresh()} disabled={loading}>
<RefreshCw size={14} className={loading ? "spin" : ""} />
Refresh
</button>
<button className="btn btn-primary btn-sm" onClick={() => setAddModalOpen(true)}>
<Plus size={14} />
Add Node
</button>
</div>
</div>
<div className="nodes-view-stats">
<div className="nodes-view-stat" data-testid="nodes-stat-total">
<span>Total</span>
<strong>{stats.total}</strong>
</div>
<div className="nodes-view-stat nodes-view-stat--online" data-testid="nodes-stat-online">
<span><Wifi size={14} /> Online</span>
<strong>{stats.online}</strong>
</div>
<div className="nodes-view-stat nodes-view-stat--offline" data-testid="nodes-stat-offline">
<span><WifiOff size={14} /> Offline</span>
<strong>{stats.offline}</strong>
</div>
<div className="nodes-view-stat" data-testid="nodes-stat-remote">
<span><Globe size={14} /> Remote</span>
<strong>{stats.remote}</strong>
</div>
</div>
{error && <div className="nodes-view-error">{error}</div>}
{loading ? (
<div className="nodes-view-grid">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="node-card node-card--loading" aria-hidden />
))}
</div>
) : nodes.length === 0 ? (
<div className="nodes-view-empty">
<p>No nodes are registered yet.</p>
<button className="btn btn-primary" onClick={() => setAddModalOpen(true)}>
<Plus size={14} />
Add First Node
</button>
</div>
) : (
<div className="nodes-view-grid">
{nodes.map((node) => (
<NodeCard
key={node.id}
node={node}
projects={projects}
onHealthCheck={(id) => { void handleHealthCheck(id); }}
onEdit={(selected) => setSelectedNode(selected)}
onRemove={(id) => { void handleUnregister(id); }}
isLoading={loading}
/>
))}
</div>
)}
<AddNodeModal
isOpen={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSubmit={handleRegister}
addToast={addToast}
/>
<NodeDetailModal
isOpen={selectedNode !== null}
onClose={() => setSelectedNode(null)}
node={selectedNode}
projects={projects}
onUpdate={handleUpdate}
onHealthCheck={handleHealthCheck}
addToast={addToast}
/>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { memo, useCallback } from "react";
import { Play, Pause, AlertCircle, Loader2, MoreHorizontal, Trash2, Folder, ArrowRight } from "lucide-react";
import type { RegisteredProject, ProjectHealth, ProjectStatus } from "@fusion/core";
import type { NodeInfo } from "../api";
export interface ProjectCardProps {
project: RegisteredProject;
@@ -9,6 +10,7 @@ export interface ProjectCardProps {
onPause: (project: RegisteredProject) => void;
onResume: (project: RegisteredProject) => void;
onRemove: (project: RegisteredProject) => void;
node?: NodeInfo;
isLoading?: boolean;
}
@@ -57,12 +59,26 @@ function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardP
if (!prevHealth && !nextHealth) return true;
if (!prevHealth || !nextHealth) return false;
if (
prevHealth.activeTaskCount !== nextHealth.activeTaskCount ||
prevHealth.inFlightAgentCount !== nextHealth.inFlightAgentCount ||
prevHealth.totalTasksCompleted !== nextHealth.totalTasksCompleted ||
prevHealth.totalTasksFailed !== nextHealth.totalTasksFailed ||
prevHealth.status !== nextHealth.status
) {
return false;
}
const prevNode = previous.node;
const nextNode = next.node;
if (!prevNode && !nextNode) return true;
if (!prevNode || !nextNode) return false;
return (
prevHealth.activeTaskCount === nextHealth.activeTaskCount &&
prevHealth.inFlightAgentCount === nextHealth.inFlightAgentCount &&
prevHealth.totalTasksCompleted === nextHealth.totalTasksCompleted &&
prevHealth.totalTasksFailed === nextHealth.totalTasksFailed &&
prevHealth.status === nextHealth.status
prevNode.id === nextNode.id
&& prevNode.name === nextNode.name
&& prevNode.status === nextNode.status
&& prevNode.type === nextNode.type
);
}
@@ -73,6 +89,7 @@ function ProjectCardInner({
onPause,
onResume,
onRemove,
node,
isLoading = false,
}: ProjectCardProps) {
const statusConfig = STATUS_CONFIG[project.status];
@@ -123,6 +140,11 @@ function ProjectCardInner({
<h3 className="project-card-name" title={project.name}>
{project.name}
</h3>
{node && (
<span className="node-badge" title={`Assigned node: ${node.name}`}>
on: {node.name}
</span>
)}
<span className="project-card-path" title={project.path}>
{truncatePath(project.path)}
</span>

View File

@@ -0,0 +1,57 @@
import { useMemo } from "react";
import type { NodeInfo } from "../api";
interface ProjectNodeSelectorProps {
projectId: string;
currentNodeId?: string;
onSelect: (nodeId: string | null) => void;
nodes: NodeInfo[];
disabled?: boolean;
}
const STATUS_DOT: Record<NodeInfo["status"], string> = {
online: "🟢",
offline: "🔴",
connecting: "🟡",
error: "🔴",
};
export function ProjectNodeSelector({
projectId,
currentNodeId,
onSelect,
nodes,
disabled = false,
}: ProjectNodeSelectorProps) {
const sortedNodes = useMemo(() => {
return [...nodes].sort((a, b) => a.name.localeCompare(b.name));
}, [nodes]);
const selectedValue = currentNodeId ?? "";
return (
<label className="project-node-selector" htmlFor={`project-node-selector-${projectId}`}>
<span className="project-node-selector__label">Runtime Node</span>
<select
id={`project-node-selector-${projectId}`}
value={selectedValue}
onChange={(event) => {
const value = event.target.value;
onSelect(value ? value : null);
}}
disabled={disabled}
>
<option value="">Auto (no assignment)</option>
{sortedNodes.map((node) => (
<option
key={node.id}
value={node.id}
className={node.status === "offline" || node.status === "error" ? "project-node-selector__option--dim" : ""}
>
{STATUS_DOT[node.status]} {node.name} ({node.type})
</option>
))}
</select>
</label>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox } from "lucide-react";
import type { ProjectInfo, ProjectHealth } from "../api";
import type { ProjectInfo, ProjectHealth, NodeInfo } from "../api";
import type { ProjectStatus } from "@fusion/core";
import { ProjectCard } from "./ProjectCard";
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
@@ -15,6 +15,7 @@ export interface ProjectOverviewProps {
onResumeProject: (project: ProjectInfo) => void;
onRemoveProject: (project: ProjectInfo) => void;
onViewAllProjects?: () => void;
nodes?: NodeInfo[];
}
type FilterTab = "all" | "active" | "paused" | "errored";
@@ -43,6 +44,7 @@ export function ProjectOverview({
onPauseProject,
onResumeProject,
onRemoveProject,
nodes = [],
}: ProjectOverviewProps) {
const [activeFilter, setActiveFilter] = useState<FilterTab>("all");
const [sortBy, setSortBy] = useState<SortOption>("activity");
@@ -327,17 +329,21 @@ export function ProjectOverview({
{/* Project grid */}
<div className="project-grid">
{sortedProjects.map(({ project, health }) => (
<ProjectCard
key={project.id}
project={project}
health={health}
onSelect={handleSelectProject}
onPause={onPauseProject}
onResume={onResumeProject}
onRemove={onRemoveProject}
/>
))}
{sortedProjects.map(({ project, health }) => {
const projectNode = nodes.find((node) => node.id === project.nodeId);
return (
<ProjectCard
key={project.id}
project={project}
health={health}
node={projectNode}
onSelect={handleSelectProject}
onPause={onPauseProject}
onResume={onResumeProject}
onRemove={onRemoveProject}
/>
);
})}
</div>
{/* No results state */}

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { NodeCard } from "../NodeCard";
import type { NodeInfo, ProjectInfo } from "../../api";
vi.mock("lucide-react", () => ({
Activity: () => <span data-testid="activity-icon">activity</span>,
Server: () => <span data-testid="server-icon">server</span>,
Settings: () => <span data-testid="settings-icon">settings</span>,
Trash2: () => <span data-testid="trash-icon">trash</span>,
}));
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
id: "node-1",
name: "Primary Node",
type: "local",
status: "online",
maxConcurrent: 3,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
return {
id: "proj-1",
name: "Project One",
path: "/workspace/project-one",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
describe("NodeCard", () => {
it("renders node name, type, status, project count, and concurrency", () => {
const node = makeNode({ id: "node-abc", name: "Build Worker", type: "remote", status: "connecting", url: "https://remote.example.com" });
const projects = [
makeProject({ id: "proj-a", nodeId: "node-abc" }),
makeProject({ id: "proj-b", nodeId: "node-abc" }),
makeProject({ id: "proj-c", nodeId: "other-node" }),
];
render(
<NodeCard
node={node}
projects={projects}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
/>
);
expect(screen.getByText("Build Worker")).toBeDefined();
expect(screen.getByText("Remote")).toBeDefined();
expect(screen.getByText("Connecting")).toBeDefined();
expect(screen.getByText("2")).toBeDefined();
expect(screen.getByText("3")).toBeDefined();
expect(screen.getByText("https://remote.example.com")).toBeDefined();
});
it("maps status classes correctly", () => {
const { rerender } = render(
<NodeCard
node={makeNode({ status: "online" })}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
/>
);
expect(screen.getByText("Online").className).toContain("node-card__status--online");
rerender(
<NodeCard
node={makeNode({ status: "offline" })}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
/>
);
expect(screen.getByText("Offline").className).toContain("node-card__status--offline");
rerender(
<NodeCard
node={makeNode({ status: "error" })}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
/>
);
expect(screen.getByText("Error").className).toContain("node-card__status--error");
});
it("fires health check and edit callbacks", () => {
const node = makeNode();
const onHealthCheck = vi.fn();
const onEdit = vi.fn();
render(
<NodeCard
node={node}
projects={[]}
onHealthCheck={onHealthCheck}
onEdit={onEdit}
onRemove={vi.fn()}
/>
);
fireEvent.click(screen.getByLabelText("Run node health check"));
expect(onHealthCheck).toHaveBeenCalledWith(node.id);
fireEvent.click(screen.getByLabelText("Edit node"));
expect(onEdit).toHaveBeenCalledWith(node);
});
it("requires a second click to confirm remove", () => {
const onRemove = vi.fn();
const node = makeNode();
render(
<NodeCard
node={node}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={onRemove}
/>
);
const removeButton = screen.getByLabelText("Remove node");
fireEvent.click(removeButton);
expect(onRemove).not.toHaveBeenCalled();
expect(screen.getByText("Confirm")).toBeDefined();
fireEvent.click(screen.getByLabelText("Confirm remove node"));
expect(onRemove).toHaveBeenCalledWith(node.id);
});
});

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { NodesView } from "../NodesView";
import type { NodeInfo, ProjectInfo } from "../../api";
import { useNodes } from "../../hooks/useNodes";
import { useProjects } from "../../hooks/useProjects";
vi.mock("../../hooks/useNodes", () => ({
useNodes: vi.fn(),
}));
vi.mock("../../hooks/useProjects", () => ({
useProjects: vi.fn(),
}));
const mockUseNodes = vi.mocked(useNodes);
const mockUseProjects = vi.mocked(useProjects);
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
id: "node-1",
name: "Primary Node",
type: "local",
status: "online",
maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
return {
id: "proj-1",
name: "Project One",
path: "/workspace/project-one",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function makeUseNodesResult(overrides: Partial<ReturnType<typeof useNodes>> = {}): ReturnType<typeof useNodes> {
return {
nodes: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
register: vi.fn().mockResolvedValue(makeNode()),
update: vi.fn().mockResolvedValue(makeNode()),
unregister: vi.fn().mockResolvedValue(undefined),
healthCheck: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
beforeEach(() => {
mockUseProjects.mockReturnValue({
projects: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
});
});
describe("NodesView", () => {
it("renders node cards and stats", () => {
mockUseProjects.mockReturnValue({
projects: [makeProject({ nodeId: "node-1" }), makeProject({ id: "proj-2", nodeId: "node-2" })],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
});
mockUseNodes.mockReturnValue(makeUseNodesResult({
nodes: [
makeNode({ id: "node-1", name: "Alpha", status: "online", type: "local" }),
makeNode({ id: "node-2", name: "Beta", status: "offline", type: "remote", url: "https://beta.node" }),
],
}));
render(<NodesView addToast={vi.fn()} />);
expect(screen.getByText("Alpha")).toBeDefined();
expect(screen.getByText("Beta")).toBeDefined();
expect(screen.getByText("2 registered")).toBeDefined();
expect(screen.getByTestId("nodes-stat-total").textContent).toContain("2");
expect(screen.getByTestId("nodes-stat-online").textContent).toContain("1");
expect(screen.getByTestId("nodes-stat-offline").textContent).toContain("1");
expect(screen.getByTestId("nodes-stat-remote").textContent).toContain("1");
});
it("renders empty state when there are no nodes", () => {
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [] }));
render(<NodesView addToast={vi.fn()} />);
expect(screen.getByText("No nodes are registered yet.")).toBeDefined();
expect(screen.getByText("Add First Node")).toBeDefined();
});
it("opens Add Node modal when Add Node button is clicked", () => {
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [] }));
render(<NodesView addToast={vi.fn()} />);
fireEvent.click(screen.getByText("Add Node"));
expect(screen.getByRole("dialog", { name: "Add Node" })).toBeDefined();
});
it("opens Node Detail modal when a node card is clicked", () => {
mockUseProjects.mockReturnValue({
projects: [makeProject({ nodeId: "node-1" })],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
});
mockUseNodes.mockReturnValue(makeUseNodesResult({
nodes: [makeNode({ id: "node-1", name: "Alpha Node" })],
}));
render(<NodesView addToast={vi.fn()} />);
fireEvent.click(screen.getByText("Alpha Node"));
expect(screen.getByRole("dialog", { name: "Node details for Alpha Node" })).toBeDefined();
});
});

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ProjectCard } from "../ProjectCard";
import type { RegisteredProject, ProjectHealth } from "@fusion/core";
import type { NodeInfo } from "../../api";
// Mock lucide-react to avoid SVG rendering issues in test env
vi.mock("lucide-react", () => ({
@@ -41,6 +42,19 @@ function makeHealth(overrides: Partial<ProjectHealth> = {}): ProjectHealth {
};
}
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
id: "node_001",
name: "Build Node",
type: "local",
status: "online",
maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
const noop = () => {};
describe("ProjectCard", () => {
@@ -60,6 +74,22 @@ describe("ProjectCard", () => {
expect(screen.getByText("/path/to/project")).toBeDefined();
});
it("renders assigned node badge when node is provided", () => {
render(
<ProjectCard
project={makeProject()}
health={makeHealth()}
node={makeNode({ name: "Remote Worker" })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(screen.getByText("on: Remote Worker")).toBeDefined();
});
it("truncates long paths", () => {
const longPath = "/very/long/path/to/the/project/directory/that/needs/truncation";
render(