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:
@@ -26,12 +26,14 @@ import { WorkflowStepManager } from "./components/WorkflowStepManager";
|
||||
import { MissionManager } from "./components/MissionManager";
|
||||
import { AgentListModal } from "./components/AgentListModal";
|
||||
import { AgentsView } from "./components/AgentsView";
|
||||
import { NodesView } from "./components/NodesView";
|
||||
import { MailboxModal } from "./components/MailboxModal";
|
||||
import { ScriptsModal } from "./components/ScriptsModal";
|
||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||
import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { useProjects } from "./hooks/useProjects";
|
||||
import { useNodes } from "./hooks/useNodes";
|
||||
import { useCurrentProject } from "./hooks/useCurrentProject";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
import { useTheme } from "./hooks/useTheme";
|
||||
@@ -44,6 +46,7 @@ function AppInner() {
|
||||
|
||||
// Project management hooks - MUST be called before any conditional logic
|
||||
const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects, register: registerProject, update: updateProjectHook, unregister: unregisterProjectHook } = useProjects();
|
||||
const { nodes } = useNodes();
|
||||
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects);
|
||||
|
||||
// Tasks hook with project context
|
||||
@@ -97,6 +100,7 @@ function AppInner() {
|
||||
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
|
||||
const [missionsOpen, setMissionsOpen] = useState(false);
|
||||
const [agentsOpen, setAgentsOpen] = useState(false);
|
||||
const [nodesOpen, setNodesOpen] = useState(false);
|
||||
const [scriptsOpen, setScriptsOpen] = useState(false);
|
||||
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
|
||||
@@ -562,6 +566,14 @@ function AppInner() {
|
||||
// Agent handlers
|
||||
const handleCloseAgents = useCallback(() => setAgentsOpen(false), []);
|
||||
|
||||
// Node management view handlers
|
||||
const handleOpenNodes = useCallback(() => {
|
||||
setNodesOpen((prev) => !prev);
|
||||
}, []);
|
||||
const handleCloseNodes = useCallback(() => {
|
||||
setNodesOpen(false);
|
||||
}, []);
|
||||
|
||||
// Scripts handlers
|
||||
const handleOpenScripts = useCallback(() => setScriptsOpen(true), []);
|
||||
const handleCloseScripts = useCallback(() => setScriptsOpen(false), []);
|
||||
@@ -585,6 +597,17 @@ function AppInner() {
|
||||
|
||||
// Render main content based on view mode
|
||||
const renderMainContent = () => {
|
||||
if (nodesOpen) {
|
||||
return (
|
||||
<div className="nodes-management-overlay">
|
||||
<div className="nodes-management-overlay__header">
|
||||
<button className="btn btn-sm" onClick={handleCloseNodes}>Close Nodes</button>
|
||||
</div>
|
||||
<NodesView addToast={addToast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === "overview") {
|
||||
return (
|
||||
<ProjectOverview
|
||||
@@ -595,6 +618,7 @@ function AppInner() {
|
||||
onPauseProject={handlePauseProject}
|
||||
onResumeProject={handleResumeProject}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
nodes={nodes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -674,6 +698,7 @@ function AppInner() {
|
||||
mailboxUnreadCount={mailboxUnreadCount}
|
||||
onOpenSchedules={handleOpenSchedules}
|
||||
onOpenGitManager={handleOpenGitManager}
|
||||
onOpenNodes={handleOpenNodes}
|
||||
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
|
||||
onOpenMissions={viewMode === "project" && currentProject ? () => setMissionsOpen(true) : undefined}
|
||||
onOpenScripts={handleOpenScripts}
|
||||
@@ -700,7 +725,7 @@ function AppInner() {
|
||||
>
|
||||
{renderMainContent()}
|
||||
</div>
|
||||
{viewMode === "project" && currentProject && (
|
||||
{viewMode === "project" && currentProject && !nodesOpen && (
|
||||
<ExecutorStatusBar
|
||||
tasks={tasks}
|
||||
projectId={currentProject.id}
|
||||
|
||||
@@ -21,6 +21,8 @@ import type {
|
||||
Message,
|
||||
MessageType,
|
||||
ParticipantType,
|
||||
NodeConfig,
|
||||
NodeStatus,
|
||||
} from "@fusion/core";
|
||||
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@fusion/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStep } from "@fusion/core";
|
||||
@@ -2193,15 +2195,16 @@ export interface ProjectCreateInput {
|
||||
|
||||
/** Node information returned by node endpoints */
|
||||
export interface NodeInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "local" | "remote";
|
||||
url?: string;
|
||||
status: "online" | "offline" | "connecting" | "error";
|
||||
id: NodeConfig["id"];
|
||||
name: NodeConfig["name"];
|
||||
type: NodeConfig["type"];
|
||||
url?: NodeConfig["url"];
|
||||
apiKey?: NodeConfig["apiKey"];
|
||||
status: NodeStatus;
|
||||
capabilities?: string[];
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
maxConcurrent: NodeConfig["maxConcurrent"];
|
||||
createdAt: NodeConfig["createdAt"];
|
||||
updatedAt: NodeConfig["updatedAt"];
|
||||
}
|
||||
|
||||
/** Input for creating a new node */
|
||||
@@ -2213,6 +2216,30 @@ export interface NodeCreateInput {
|
||||
maxConcurrent?: number;
|
||||
}
|
||||
|
||||
/** Input for updating an existing node */
|
||||
export type NodeUpdateInput = Partial<Pick<NodeCreateInput, "name" | "type" | "url" | "apiKey" | "maxConcurrent">> & {
|
||||
status?: NodeStatus;
|
||||
capabilities?: string[];
|
||||
};
|
||||
|
||||
/** Result from a node health check */
|
||||
export interface NodeHealthCheckResult {
|
||||
nodeId: string;
|
||||
status: NodeStatus;
|
||||
responseTimeMs?: number;
|
||||
error?: string;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/** Runtime metrics for a node */
|
||||
export interface NodeMetrics {
|
||||
nodeId: string;
|
||||
activeTaskCount: number;
|
||||
inFlightAgentCount: number;
|
||||
uptimeMs: number;
|
||||
lastActivityAt?: string;
|
||||
}
|
||||
|
||||
/** Options for fetching activity feed */
|
||||
export interface FeedOptions {
|
||||
limit?: number;
|
||||
@@ -2295,7 +2322,7 @@ export function fetchNode(id: string): Promise<NodeInfo> {
|
||||
}
|
||||
|
||||
/** Update an existing node */
|
||||
export function updateNode(id: string, updates: Partial<NodeInfo>): Promise<NodeInfo> {
|
||||
export function updateNode(id: string, updates: NodeUpdateInput): Promise<NodeInfo> {
|
||||
return api<NodeInfo>(`/nodes/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
@@ -2310,15 +2337,30 @@ export function unregisterNode(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
/** Trigger a node health check */
|
||||
export function checkNodeHealth(id: string): Promise<{ status: string }> {
|
||||
return api<{ status: string }>(`/nodes/${encodeURIComponent(id)}/health-check`, {
|
||||
export async function checkNodeHealth(id: string): Promise<NodeHealthCheckResult> {
|
||||
const result = await api<Partial<NodeHealthCheckResult> & { status: NodeStatus }>(`/nodes/${encodeURIComponent(id)}/health-check`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
return {
|
||||
nodeId: result.nodeId ?? id,
|
||||
status: result.status,
|
||||
responseTimeMs: result.responseTimeMs,
|
||||
error: result.error,
|
||||
checkedAt: result.checkedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Fetch runtime metrics for a node */
|
||||
export function fetchNodeMetrics(id: string): Promise<Record<string, unknown>> {
|
||||
return api<Record<string, unknown>>(`/nodes/${encodeURIComponent(id)}/metrics`);
|
||||
export async function fetchNodeMetrics(id: string): Promise<NodeMetrics> {
|
||||
const metrics = await api<Partial<NodeMetrics> & { activeTasks?: number }>(`/nodes/${encodeURIComponent(id)}/metrics`);
|
||||
return {
|
||||
nodeId: metrics.nodeId ?? id,
|
||||
activeTaskCount: metrics.activeTaskCount ?? metrics.activeTasks ?? 0,
|
||||
inFlightAgentCount: metrics.inFlightAgentCount ?? 0,
|
||||
uptimeMs: metrics.uptimeMs ?? 0,
|
||||
lastActivityAt: metrics.lastActivityAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Browse directory entries for the directory picker */
|
||||
|
||||
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(
|
||||
|
||||
233
packages/dashboard/app/hooks/__tests__/useNodes.test.ts
Normal file
233
packages/dashboard/app/hooks/__tests__/useNodes.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useNodes } from "../useNodes";
|
||||
import * as api from "../../api";
|
||||
import type { NodeInfo, NodeCreateInput } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchNodes: vi.fn(),
|
||||
registerNode: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
unregisterNode: vi.fn(),
|
||||
checkNodeHealth: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchNodes = vi.mocked(api.fetchNodes);
|
||||
const mockRegisterNode = vi.mocked(api.registerNode);
|
||||
const mockUpdateNode = vi.mocked(api.updateNode);
|
||||
const mockUnregisterNode = vi.mocked(api.unregisterNode);
|
||||
const mockCheckNodeHealth = vi.mocked(api.checkNodeHealth);
|
||||
|
||||
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||
return {
|
||||
id: "node_local",
|
||||
name: "Local Node",
|
||||
type: "local",
|
||||
status: "online",
|
||||
capabilities: ["executor"],
|
||||
maxConcurrent: 2,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("useNodes", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockFetchNodes.mockReset();
|
||||
mockRegisterNode.mockReset();
|
||||
mockUpdateNode.mockReset();
|
||||
mockUnregisterNode.mockReset();
|
||||
mockCheckNodeHealth.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fetches nodes on mount", async () => {
|
||||
mockFetchNodes.mockResolvedValueOnce([makeNode()]);
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.nodes).toHaveLength(1);
|
||||
expect(result.current.nodes[0].name).toBe("Local Node");
|
||||
});
|
||||
|
||||
it("handles fetch error gracefully", async () => {
|
||||
mockFetchNodes.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe("boom");
|
||||
});
|
||||
|
||||
it("register adds node optimistically", async () => {
|
||||
mockFetchNodes.mockResolvedValueOnce([]);
|
||||
const nodeInput: NodeCreateInput = { name: "Remote Node", type: "remote", url: "https://node.test" };
|
||||
const createdNode = makeNode({
|
||||
id: "node_remote",
|
||||
name: "Remote Node",
|
||||
type: "remote",
|
||||
url: "https://node.test",
|
||||
status: "connecting",
|
||||
});
|
||||
mockRegisterNode.mockResolvedValueOnce(createdNode);
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.register(nodeInput);
|
||||
});
|
||||
|
||||
expect(mockRegisterNode).toHaveBeenCalledWith(nodeInput);
|
||||
expect(result.current.nodes).toHaveLength(1);
|
||||
expect(result.current.nodes[0].id).toBe("node_remote");
|
||||
});
|
||||
|
||||
it("update modifies node optimistically", async () => {
|
||||
mockFetchNodes.mockResolvedValueOnce([makeNode()]);
|
||||
const updatedNode = makeNode({ name: "Renamed Node", maxConcurrent: 4 });
|
||||
mockUpdateNode.mockResolvedValueOnce(updatedNode);
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update("node_local", { name: "Renamed Node", maxConcurrent: 4 });
|
||||
});
|
||||
|
||||
expect(mockUpdateNode).toHaveBeenCalledWith("node_local", { name: "Renamed Node", maxConcurrent: 4 });
|
||||
expect(result.current.nodes[0].name).toBe("Renamed Node");
|
||||
});
|
||||
|
||||
it("unregister removes node optimistically", async () => {
|
||||
mockFetchNodes.mockResolvedValueOnce([makeNode()]);
|
||||
mockUnregisterNode.mockResolvedValueOnce(undefined);
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.nodes).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.unregister("node_local");
|
||||
});
|
||||
|
||||
expect(mockUnregisterNode).toHaveBeenCalledWith("node_local");
|
||||
expect(result.current.nodes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("healthCheck updates node status in local state", async () => {
|
||||
mockFetchNodes.mockResolvedValueOnce([makeNode({ status: "offline" })]);
|
||||
mockCheckNodeHealth.mockResolvedValueOnce({
|
||||
nodeId: "node_local",
|
||||
status: "online",
|
||||
checkedAt: "2026-01-03T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.nodes[0].status).toBe("offline");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.healthCheck("node_local");
|
||||
});
|
||||
|
||||
expect(mockCheckNodeHealth).toHaveBeenCalledWith("node_local");
|
||||
expect(result.current.nodes[0].status).toBe("online");
|
||||
expect(result.current.nodes[0].updatedAt).toBe("2026-01-03T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("refresh manually refetches nodes", async () => {
|
||||
mockFetchNodes
|
||||
.mockResolvedValueOnce([makeNode({ name: "Before Refresh" })])
|
||||
.mockResolvedValueOnce([makeNode({ name: "After Refresh" })]);
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.nodes[0].name).toBe("Before Refresh");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.nodes[0].name).toBe("After Refresh");
|
||||
});
|
||||
|
||||
it("refetches when visibility changes back to visible", async () => {
|
||||
const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
mockFetchNodes
|
||||
.mockResolvedValueOnce([makeNode({ name: "Initial" })])
|
||||
.mockResolvedValueOnce([makeNode({ name: "Visible Again" })]);
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1100);
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.nodes[0].name).toBe("Visible Again");
|
||||
|
||||
if (originalVisibilityState) {
|
||||
Object.defineProperty(document, "visibilityState", originalVisibilityState);
|
||||
}
|
||||
});
|
||||
});
|
||||
149
packages/dashboard/app/hooks/useNodes.ts
Normal file
149
packages/dashboard/app/hooks/useNodes.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { NodeCreateInput, NodeInfo, NodeUpdateInput } from "../api";
|
||||
import {
|
||||
fetchNodes,
|
||||
registerNode,
|
||||
updateNode,
|
||||
unregisterNode,
|
||||
checkNodeHealth,
|
||||
} from "../api";
|
||||
|
||||
export interface UseNodesResult {
|
||||
nodes: NodeInfo[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
register: (input: NodeCreateInput) => Promise<NodeInfo>;
|
||||
update: (id: string, updates: NodeUpdateInput) => Promise<NodeInfo>;
|
||||
unregister: (id: string) => Promise<void>;
|
||||
healthCheck: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 10000; // 10 seconds
|
||||
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
||||
|
||||
/**
|
||||
* Hook for fetching and managing node registry state.
|
||||
* Automatically polls for updates every 10 seconds.
|
||||
* Refetches when the tab becomes visible again.
|
||||
*/
|
||||
export function useNodes(): UseNodesResult {
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastVisibilityRefreshRef = useRef<number>(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await fetchNodes();
|
||||
setNodes(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch nodes");
|
||||
// Keep stale data visible if polling refresh fails
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchNodes();
|
||||
if (!cancelled) {
|
||||
setNodes(data);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch nodes");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
|
||||
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastVisibilityRefreshRef.current = now;
|
||||
void refresh();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
void refresh();
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [loading, refresh]);
|
||||
|
||||
const register = useCallback(async (input: NodeCreateInput): Promise<NodeInfo> => {
|
||||
const node = await registerNode(input);
|
||||
setNodes((prev) => [...prev, node]);
|
||||
return node;
|
||||
}, []);
|
||||
|
||||
const update = useCallback(async (id: string, updates: NodeUpdateInput): Promise<NodeInfo> => {
|
||||
const node = await updateNode(id, updates);
|
||||
setNodes((prev) => prev.map((existing) => (existing.id === id ? node : existing)));
|
||||
return node;
|
||||
}, []);
|
||||
|
||||
const unregister = useCallback(async (id: string): Promise<void> => {
|
||||
await unregisterNode(id);
|
||||
setNodes((prev) => prev.filter((node) => node.id !== id));
|
||||
}, []);
|
||||
|
||||
const healthCheck = useCallback(async (id: string): Promise<void> => {
|
||||
const result = await checkNodeHealth(id);
|
||||
setNodes((prev) => prev.map((node) => (
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
status: result.status,
|
||||
updatedAt: result.checkedAt,
|
||||
}
|
||||
: node
|
||||
)));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
register,
|
||||
update,
|
||||
unregister,
|
||||
healthCheck,
|
||||
};
|
||||
}
|
||||
@@ -22051,3 +22051,446 @@ html .column.drag-over * {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* === Node Management === */
|
||||
.nodes-management-overlay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
min-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.nodes-management-overlay__header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.nodes-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md) var(--space-xl);
|
||||
}
|
||||
|
||||
.nodes-view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.nodes-view-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.nodes-view-title h2 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.nodes-view-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.nodes-view-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.nodes-view-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.nodes-view-stat {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.nodes-view-stat span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nodes-view-stat strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nodes-view-stat--online strong {
|
||||
color: var(--success, var(--color-success));
|
||||
}
|
||||
|
||||
.nodes-view-stat--offline strong {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.nodes-view-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.nodes-view-empty {
|
||||
padding: var(--space-xl);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.nodes-view-error {
|
||||
color: var(--color-error);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.node-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.node-card--loading {
|
||||
min-height: 220px;
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.node-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.node-card__title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-card__icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.node-card__name {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.node-card__meta-row {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.node-card__type-badge {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.node-card__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.node-card__status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.node-card__status--online .node-card__status-indicator {
|
||||
background: var(--success, var(--color-success));
|
||||
}
|
||||
|
||||
.node-card__status--offline .node-card__status-indicator,
|
||||
.node-card__status--error .node-card__status-indicator {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.node-card__status--connecting .node-card__status-indicator {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.node-card__url {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.node-card__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-card__metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.node-card__metric-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.node-card__metric-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.node-card__actions {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.node-card__action {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-card__action:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.node-card__action--remove {
|
||||
margin-left: auto;
|
||||
color: var(--color-error);
|
||||
border-color: color-mix(in srgb, var(--color-error) 45%, var(--border));
|
||||
}
|
||||
|
||||
.node-card__action--remove.is-armed {
|
||||
background: color-mix(in srgb, var(--color-error) 14%, transparent);
|
||||
}
|
||||
|
||||
.add-node-modal {
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.add-node-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.add-node-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.add-node-modal__field input,
|
||||
.add-node-modal__field select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.add-node-modal__error {
|
||||
color: var(--color-error);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-detail-modal {
|
||||
max-width: 860px;
|
||||
width: min(860px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.node-detail-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
max-height: min(72vh, 680px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.node-detail-modal__section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.node-detail-modal__section h4 {
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.node-detail-modal__section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.node-detail-modal__field--full {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.node-detail-modal__field span {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-detail-modal__edit-actions {
|
||||
margin-top: var(--space-sm);
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.node-detail-modal__project-list {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-detail-modal__project-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__project-item code {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.node-detail-modal__empty {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.node-detail-modal__health-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.node-detail-modal__actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.project-node-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.project-node-selector__label {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-node-selector select {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.project-node-selector__option--dim {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.node-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 36%, transparent);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
width: fit-content;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nodes-view {
|
||||
padding-inline: var(--space-sm);
|
||||
}
|
||||
|
||||
.nodes-view-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.node-card__metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.node-detail-modal__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.node-detail-modal__field--full {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user