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