feat(FN-3509): document node mapping availability UI in dashboard guide

Documents the node mapping availability UI in the dashboard guide.

Fusion-Task-Id: FN-3509
This commit is contained in:
Fusion
2026-05-08 18:40:00 -07:00
committed by gsxdsm
parent c0d462a12d
commit ccefdd2c52
13 changed files with 464 additions and 730 deletions

View File

@@ -417,6 +417,22 @@ This keeps remote path mappings anchored to remote-authoritative data instead of
| Offline | Red | Node is unreachable or shut down | | Offline | Red | Node is unreachable or shut down |
| Connecting | Yellow (pulsing) | Connection attempt in progress | | Connecting | Yellow (pulsing) | Connection attempt in progress |
### Project availability and path visibility
Node and project surfaces now use per-node project mappings (`nodeMappings`) instead of a single `project.nodeId` assumption.
- **Node cards / counts** include only projects with an `available: true` mapping for that node.
- **Node Details modal** lists one row per project available on the selected node and shows:
- project name
- project ID
- configured path for that node
- **Project node filter** in the Projects view is built from available mappings and uses canonical node-name resolution (`Node.name` → mapping name → source node name → node ID).
- **Project cards** show node availability as compact `Node → /path` rows:
- up to 3 rows inline
- `+N more` summary when additional mappings exist
- single-node projects still show the configured path clearly
- Mappings marked `available: false` are excluded from node counts, node filter options, node detail project rows, and project-card availability summaries.
### Persistence ### Persistence
The selected node persists across browser sessions via localStorage. If the selected remote node is unregistered, the dashboard automatically falls back to local mode. The selected node persists across browser sessions via localStorage. If the selected remote node is unregistered, the dashboard automatically falls back to local mode.

View File

@@ -5722,10 +5722,29 @@ export function fetchProjects(): Promise<ProjectInfo[]> {
return api<ProjectInfo[]>("/projects"); return api<ProjectInfo[]>("/projects");
} }
/** Project info with source node metadata (added by server for remote projects) */ /** Dashboard-facing mapping contract for project availability on nodes. */
export interface ProjectNodeAvailability {
nodeId: string;
nodeName?: string;
path: string;
available: boolean;
}
/** Project info with source node metadata (added by server for remote projects). */
export interface ProjectInfoWithSource extends ProjectInfo { export interface ProjectInfoWithSource extends ProjectInfo {
/** Name of the source node (added by server for remote projects) */ /** Name of the source node (added by server for remote projects). */
_sourceNodeName?: string; _sourceNodeName?: string;
/** Normalized per-node project mappings for dashboard UI. */
nodeMappings?: ProjectNodeAvailability[];
/** Compatibility fields accepted from in-flight server rollouts. */
projectNodeMappings?: ProjectNodeAvailability[];
pathMappings?: ProjectNodeAvailability[];
}
export function hasNodeMappingsSupport(project: ProjectInfoWithSource): boolean {
return Array.isArray(project.nodeMappings)
|| Array.isArray(project.projectNodeMappings)
|| Array.isArray(project.pathMappings);
} }
/** Fetch all registered projects from all nodes (local + remote) */ /** Fetch all registered projects from all nodes (local + remote) */

View File

@@ -189,17 +189,17 @@
} }
.project-card-action-resume:hover:not(:disabled) { .project-card-action-resume:hover:not(:disabled) {
background: rgba(63, 185, 80, 0.1); background: color-mix(in srgb, var(--color-success) 10%, transparent);
border-color: var(--color-success); border-color: var(--color-success);
} }
.project-card-action-pause { .project-card-action-pause {
color: var(--warning, #e3b341); color: var(--color-warning);
} }
.project-card-action-pause:hover:not(:disabled) { .project-card-action-pause:hover:not(:disabled) {
background: rgba(227, 179, 65, 0.1); background: color-mix(in srgb, var(--color-warning) 10%, transparent);
border-color: var(--warning, #e3b341); border-color: var(--color-warning);
} }
.project-card-action-open { .project-card-action-open {
@@ -207,7 +207,7 @@
} }
.project-card-action-open:hover:not(:disabled) { .project-card-action-open:hover:not(:disabled) {
background: rgba(88, 166, 255, 0.1); background: color-mix(in srgb, var(--todo) 10%, transparent);
border-color: var(--todo); border-color: var(--todo);
} }
@@ -217,10 +217,59 @@
} }
.project-card-action-remove:hover:not(:disabled) { .project-card-action-remove:hover:not(:disabled) {
background: rgba(248, 81, 73, 0.1); background: color-mix(in srgb, var(--color-error) 10%, transparent);
border-color: var(--color-error); border-color: var(--color-error);
} }
.project-card-action-remove.is-armed { .project-card-action-remove.is-armed {
background: color-mix(in srgb, var(--color-error) 14%, transparent); background: color-mix(in srgb, var(--color-error) 14%, transparent);
} }
.project-card-availability {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.project-card-availability__row {
display: grid;
grid-template-columns: minmax(0, auto) auto minmax(0, 1fr);
gap: var(--space-xs);
align-items: center;
font-size: calc(var(--space-sm) + var(--space-xs));
color: var(--text-muted);
}
.project-card-availability__node {
color: var(--text);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-card-availability__arrow {
color: var(--text-dim);
}
.project-card-availability__path {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-card-availability__more {
color: var(--text-dim);
font-size: calc(var(--space-sm) + var(--space-xs));
}
@media (max-width: 768px) {
.project-card-availability__row {
grid-template-columns: minmax(0, 1fr);
}
.project-card-availability__arrow {
display: none;
}
}

View File

@@ -2,7 +2,7 @@ import { memo, useCallback, useState } from "react";
import { Play, Pause, AlertCircle, Loader2, Trash2, Folder, ArrowRight } from "lucide-react"; import { Play, Pause, AlertCircle, Loader2, Trash2, Folder, ArrowRight } from "lucide-react";
import "./ProjectCard.css"; import "./ProjectCard.css";
import type { RegisteredProject, ProjectHealth, ProjectStatus } from "@fusion/core"; import type { RegisteredProject, ProjectHealth, ProjectStatus } from "@fusion/core";
import type { NodeInfo } from "../api"; import type { ProjectNodeAvailability } from "../api";
export interface ProjectCardProps { export interface ProjectCardProps {
project: RegisteredProject; project: RegisteredProject;
@@ -11,17 +11,15 @@ export interface ProjectCardProps {
onPause: (project: RegisteredProject) => void; onPause: (project: RegisteredProject) => void;
onResume: (project: RegisteredProject) => void; onResume: (project: RegisteredProject) => void;
onRemove: (project: RegisteredProject) => void; onRemove: (project: RegisteredProject) => void;
node?: NodeInfo; availabilityMappings?: Array<ProjectNodeAvailability & { displayName: string }>;
/** Fallback node name when the node object is not available (e.g., for remote projects) */
nodeNameFallback?: string;
isLoading?: boolean; isLoading?: boolean;
} }
const STATUS_CONFIG: Record<ProjectStatus, { label: string; color: string; icon: typeof Play }> = { const STATUS_CONFIG: Record<ProjectStatus, { label: string; color: string; icon: typeof Play }> = {
active: { label: "Active", color: "var(--success)", icon: Play }, active: { label: "Active", color: "var(--color-success)", icon: Play },
paused: { label: "Paused", color: "var(--warning)", icon: Pause }, paused: { label: "Paused", color: "var(--color-warning)", icon: Pause },
errored: { label: "Error", color: "var(--color-error)", icon: AlertCircle }, errored: { label: "Error", color: "var(--color-error)", icon: AlertCircle },
initializing: { label: "Initializing", color: "var(--info)", icon: Loader2 }, initializing: { label: "Initializing", color: "var(--color-warning)", icon: Loader2 },
}; };
function formatRelativeTime(timestamp: string | undefined): string { function formatRelativeTime(timestamp: string | undefined): string {
@@ -72,21 +70,18 @@ function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardP
return false; return false;
} }
const prevNode = previous.node; const prevMappings = previous.availabilityMappings ?? [];
const nextNode = next.node; const nextMappings = next.availabilityMappings ?? [];
if (!prevNode && !nextNode) { if (prevMappings.length !== nextMappings.length) return false;
// Compare fallback names
if (previous.nodeNameFallback !== next.nodeNameFallback) return false;
return true;
}
if (!prevNode || !nextNode) return false;
return ( return prevMappings.every((mapping, index) => {
prevNode.id === nextNode.id const nextMapping = nextMappings[index];
&& prevNode.name === nextNode.name return Boolean(nextMapping)
&& prevNode.status === nextNode.status && mapping.nodeId === nextMapping.nodeId
&& prevNode.type === nextNode.type && mapping.path === nextMapping.path
); && mapping.displayName === nextMapping.displayName
&& mapping.available === nextMapping.available;
});
} }
function ProjectCardInner({ function ProjectCardInner({
@@ -96,8 +91,7 @@ function ProjectCardInner({
onPause, onPause,
onResume, onResume,
onRemove, onRemove,
node, availabilityMappings = [],
nodeNameFallback,
isLoading = false, isLoading = false,
}: ProjectCardProps) { }: ProjectCardProps) {
const [removeArmed, setRemoveArmed] = useState(false); const [removeArmed, setRemoveArmed] = useState(false);
@@ -155,10 +149,19 @@ function ProjectCardInner({
<h3 className="project-card-name" title={project.name}> <h3 className="project-card-name" title={project.name}>
{project.name} {project.name}
</h3> </h3>
{(node || nodeNameFallback) && ( {availabilityMappings.length > 0 && (
<span className="node-badge" title={`Assigned node: ${node?.name ?? nodeNameFallback}`}> <div className="project-card-availability" aria-label="Project node availability">
on: {node?.name ?? nodeNameFallback} {availabilityMappings.slice(0, 3).map((mapping) => (
</span> <div key={`${mapping.nodeId}-${mapping.path}`} className="project-card-availability__row" title={`${mapping.displayName}${mapping.path}`}>
<span className="project-card-availability__node">{mapping.displayName}</span>
<span className="project-card-availability__arrow"></span>
<code className="project-card-availability__path">{truncatePath(mapping.path, 28)}</code>
</div>
))}
{availabilityMappings.length > 3 && (
<span className="project-card-availability__more">+{availabilityMappings.length - 3} more</span>
)}
</div>
)} )}
<span className="project-card-path" title={project.path}> <span className="project-card-path" title={project.path}>
{truncatePath(project.path)} {truncatePath(project.path)}

View File

@@ -1,9 +1,10 @@
import { useState, useMemo, useCallback, useEffect } from "react"; import { useState, useMemo, useCallback, useEffect } from "react";
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox, Server } from "lucide-react"; import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox, Server } from "lucide-react";
import "./ProjectOverview.css"; import "./ProjectOverview.css";
import type { ProjectInfo, ProjectHealth, NodeInfo, ProjectInfoWithSource } from "../api"; import type { ProjectInfo, ProjectHealth, NodeInfo, ProjectInfoWithSource, ProjectNodeAvailability } from "../api";
import type { ProjectStatus } from "@fusion/core"; import type { ProjectStatus } from "@fusion/core";
import { ProjectCard } from "./ProjectCard"; import { ProjectCard } from "./ProjectCard";
import { getNodeMappingsForProject, resolveNodeDisplayName } from "../utils/nodeProjectAssignment";
import { ProjectGridSkeleton } from "./ProjectGridSkeleton"; import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
import { useProjectHealth } from "../hooks/useProjectHealth"; import { useProjectHealth } from "../hooks/useProjectHealth";
@@ -27,6 +28,10 @@ interface ProjectWithHealth {
health: ProjectHealth | null; health: ProjectHealth | null;
} }
interface DisplayMapping extends ProjectNodeAvailability {
displayName: string;
}
/** /**
* ProjectOverview - Multi-project grid view with stats and filtering * ProjectOverview - Multi-project grid view with stats and filtering
* *
@@ -92,7 +97,7 @@ export function ProjectOverview({
// Filter by node if a node filter is active // Filter by node if a node filter is active
if (activeNodeFilter !== null) { if (activeNodeFilter !== null) {
filtered = filtered.filter(({ project }) => project.nodeId === activeNodeFilter); filtered = filtered.filter(({ project }) => getNodeMappingsForProject(project).some((mapping) => mapping.available && mapping.nodeId === activeNodeFilter));
} }
return filtered; return filtered;
@@ -140,13 +145,15 @@ export function ProjectOverview({
const erroredProjects = projects.filter((p) => p.status === "errored").length; const erroredProjects = projects.filter((p) => p.status === "errored").length;
// Count unique nodes with projects (local + remote) // Count unique nodes with projects (local + remote)
const nodesWithProjects = new Set<string | undefined>(); const nodesWithProjects = new Set<string>();
projects.forEach((p) => { projects.forEach((project) => {
if (p.nodeId) { getNodeMappingsForProject(project).forEach((mapping) => {
nodesWithProjects.add(p.nodeId); if (mapping.available) {
} nodesWithProjects.add(mapping.nodeId);
}
});
}); });
const totalNodes = nodesWithProjects.size || (totalProjects > 0 ? 1 : 0); const totalNodes = nodesWithProjects.size;
let totalActiveTasks = 0; let totalActiveTasks = 0;
let totalCompletedTasks = 0; let totalCompletedTasks = 0;
@@ -183,24 +190,25 @@ export function ProjectOverview({
// Node filter options with project counts // Node filter options with project counts
const nodeFilterOptions = useMemo(() => { const nodeFilterOptions = useMemo(() => {
const nodeCounts = new Map<string | undefined, { name: string; count: number }>(); const nodeCounts = new Map<string, { name: string; count: number }>();
projects.forEach((p) => { projects.forEach((project) => {
const nodeId = p.nodeId; getNodeMappingsForProject(project)
const existing = nodeCounts.get(nodeId); .filter((mapping) => mapping.available)
.forEach((mapping) => {
if (existing) { const nodeId = mapping.nodeId;
existing.count++; const existing = nodeCounts.get(nodeId);
} else { const resolvedName = resolveNodeDisplayName(nodeId, mapping, nodes, project);
// Get the node name from the nodes list or use _sourceNodeName for remote projects if (existing) {
const localNode = nodes.find((n) => n.id === nodeId); existing.count += 1;
const nodeName = localNode?.name ?? p._sourceNodeName ?? "Local"; } else {
nodeCounts.set(nodeId, { name: nodeName, count: 1 }); nodeCounts.set(nodeId, { name: resolvedName, count: 1 });
} }
});
}); });
return Array.from(nodeCounts.entries()).map(([nodeId, { name, count }]) => ({ return Array.from(nodeCounts.entries()).map(([nodeId, { name, count }]) => ({
nodeId: nodeId ?? null, nodeId,
name, name,
count, count,
})); }));
@@ -404,16 +412,19 @@ export function ProjectOverview({
{/* Project grid */} {/* Project grid */}
<div className="project-grid"> <div className="project-grid">
{sortedProjects.map(({ project, health }) => { {sortedProjects.map(({ project, health }) => {
const projectNode = nodes.find((node) => node.id === project.nodeId); const availabilityMappings: DisplayMapping[] = getNodeMappingsForProject(project)
// Fallback: use server-provided _sourceNodeName for remote projects .filter((mapping) => mapping.available)
const nodeNameFallback = !projectNode ? project._sourceNodeName : undefined; .map((mapping) => ({
...mapping,
displayName: resolveNodeDisplayName(mapping.nodeId, mapping, nodes, project),
}));
return ( return (
<ProjectCard <ProjectCard
key={project.id} key={project.id}
project={project} project={project}
health={health} health={health}
node={projectNode} availabilityMappings={availabilityMappings}
nodeNameFallback={nodeNameFallback}
onSelect={handleSelectProject} onSelect={handleSelectProject}
onPause={onPauseProject} onPause={onPauseProject}
onResume={onResumeProject} onResume={onResumeProject}

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import type { JSX } from "react"; import type { JSX } from "react";
import { NodeCard } from "../NodeCard"; import { NodeCard } from "../NodeCard";
import type { NodeInfo, ProjectInfo } from "../../api"; import type { NodeInfo, ProjectInfoWithSource } from "../../api";
import type { ComputedNodeSyncStatus } from "../../hooks/useNodeSettingsSync"; import type { ComputedNodeSyncStatus } from "../../hooks/useNodeSettingsSync";
vi.mock("lucide-react", () => ({ vi.mock("lucide-react", () => ({
@@ -47,8 +47,8 @@ function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
}; };
} }
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo { function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
return { const project: ProjectInfoWithSource = {
id: "proj-1", id: "proj-1",
name: "Project One", name: "Project One",
path: "/workspace/project-one", path: "/workspace/project-one",
@@ -58,6 +58,12 @@ function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
updatedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
...overrides, ...overrides,
}; };
if (!project.nodeMappings && project.nodeId) {
project.nodeMappings = [{ nodeId: project.nodeId, path: project.path, available: true }];
}
return project;
} }
function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): ComputedNodeSyncStatus { function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): ComputedNodeSyncStatus {
@@ -193,12 +199,12 @@ describe("NodeCard", () => {
expect(onRemove).toHaveBeenCalledWith(node.id); expect(onRemove).toHaveBeenCalledWith(node.id);
}); });
it("local node counts include unassigned projects", () => { it("local node counts only available mappings", () => {
const localNode = makeNode({ id: "local-1", type: "local" }); const localNode = makeNode({ id: "local-1", type: "local" });
const projects = [ const projects = [
makeProject({ id: "proj-1", nodeId: "local-1" }), // explicitly assigned makeProject({ id: "proj-1", nodeId: "local-1" }),
makeProject({ id: "proj-2", nodeId: undefined }), // unassigned - runs on local makeProject({ id: "proj-2", nodeMappings: [{ nodeId: "local-1", path: "/workspace/project-two", available: false }] }),
makeProject({ id: "proj-3", nodeId: "remote-1" }), // assigned to remote - not counted makeProject({ id: "proj-3", nodeId: "remote-1" }),
]; ];
render( render(
@@ -211,8 +217,7 @@ describe("NodeCard", () => {
/> />
); );
// Local node should show 2 projects (explicitly assigned + unassigned) expect(screen.getByText("1")).toBeDefined();
expect(screen.getByText("2")).toBeDefined();
}); });
it("remote node counts exclude unassigned projects", () => { it("remote node counts exclude unassigned projects", () => {

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { NodesView } from "../NodesView"; import { NodesView } from "../NodesView";
import type { NodeInfo, ProjectInfo } from "../../api"; import type { NodeInfo, ProjectInfoWithSource } from "../../api";
import { useNodes } from "../../hooks/useNodes"; import { useNodes } from "../../hooks/useNodes";
import { useProjects } from "../../hooks/useProjects"; import { useProjects } from "../../hooks/useProjects";
import { useNodeSettingsSync } from "../../hooks/useNodeSettingsSync"; import { useNodeSettingsSync } from "../../hooks/useNodeSettingsSync";
@@ -62,8 +62,8 @@ function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
}; };
} }
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo { function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
return { const project: ProjectInfoWithSource = {
id: "proj-1", id: "proj-1",
name: "Project One", name: "Project One",
path: "/workspace/project-one", path: "/workspace/project-one",
@@ -73,6 +73,12 @@ function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
updatedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
...overrides, ...overrides,
}; };
if (!project.nodeMappings && project.nodeId) {
project.nodeMappings = [{ nodeId: project.nodeId, path: project.path, available: true }];
}
return project;
} }
function makeUseNodesResult(overrides: Partial<ReturnType<typeof useNodes>> = {}): ReturnType<typeof useNodes> { function makeUseNodesResult(overrides: Partial<ReturnType<typeof useNodes>> = {}): ReturnType<typeof useNodes> {
@@ -274,11 +280,11 @@ describe("NodesView", () => {
expect(screen.getByRole("dialog", { name: "Node details for Detail Node" })).toBeDefined(); expect(screen.getByRole("dialog", { name: "Node details for Detail Node" })).toBeDefined();
}); });
it("local node project count includes unassigned projects in detail modal", () => { it("local node detail modal only counts projects with available mappings", () => {
mockUseProjects.mockReturnValue({ mockUseProjects.mockReturnValue({
projects: [ projects: [
makeProject({ id: "proj-1", nodeId: "node-1" }), // explicitly assigned makeProject({ id: "proj-1", nodeId: "node-1" }),
makeProject({ id: "proj-2", nodeId: undefined }), // unassigned - runs on local makeProject({ id: "proj-2", nodeMappings: [{ nodeId: "node-1", path: "/workspace/project-two", available: false }] }),
], ],
loading: false, loading: false,
error: null, error: null,
@@ -299,8 +305,7 @@ describe("NodesView", () => {
expect(nodeCard).toBeInTheDocument(); expect(nodeCard).toBeInTheDocument();
fireEvent.click(nodeCard!); fireEvent.click(nodeCard!);
// Modal should show "Projects (2)" - including the unassigned project expect(screen.getByText("Projects (1)")).toBeDefined();
expect(screen.getByText("Projects (2)")).toBeDefined();
}); });
it("renders close button and calls onClose when clicked", () => { it("renders close button and calls onClose when clicked", () => {

View File

@@ -2,7 +2,6 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import { ProjectCard } from "../ProjectCard"; import { ProjectCard } from "../ProjectCard";
import type { RegisteredProject, ProjectHealth } from "@fusion/core"; import type { RegisteredProject, ProjectHealth } from "@fusion/core";
import type { NodeInfo } from "../../api";
// Mock lucide-react to avoid SVG rendering issues in test env // Mock lucide-react to avoid SVG rendering issues in test env
vi.mock("lucide-react", () => ({ vi.mock("lucide-react", () => ({
@@ -42,19 +41,6 @@ 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 = () => {}; const noop = () => {};
describe("ProjectCard", () => { describe("ProjectCard", () => {
@@ -74,12 +60,12 @@ describe("ProjectCard", () => {
expect(screen.getByText("/path/to/project")).toBeDefined(); expect(screen.getByText("/path/to/project")).toBeDefined();
}); });
it("renders assigned node badge when node is provided", () => { it("renders node availability rows with path details", () => {
render( render(
<ProjectCard <ProjectCard
project={makeProject()} project={makeProject()}
health={makeHealth()} health={makeHealth()}
node={makeNode({ name: "Remote Worker" })} availabilityMappings={[{ nodeId: "node-1", displayName: "Remote Worker", path: "/srv/work", available: true }]}
onSelect={noop} onSelect={noop}
onPause={noop} onPause={noop}
onResume={noop} onResume={noop}
@@ -87,7 +73,29 @@ describe("ProjectCard", () => {
/> />
); );
expect(screen.getByText("on: Remote Worker")).toBeDefined(); expect(screen.getByText("Remote Worker")).toBeDefined();
expect(screen.getByText("/srv/work")).toBeDefined();
});
it("shows overflow indicator when more than three mappings exist", () => {
render(
<ProjectCard
project={makeProject()}
health={makeHealth()}
availabilityMappings={[
{ nodeId: "node-1", displayName: "Node One", path: "/one", available: true },
{ nodeId: "node-2", displayName: "Node Two", path: "/two", available: true },
{ nodeId: "node-3", displayName: "Node Three", path: "/three", available: true },
{ nodeId: "node-4", displayName: "Node Four", path: "/four", available: true },
]}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(screen.getByText("+1 more")).toBeDefined();
}); });
it("truncates long paths", () => { it("truncates long paths", () => {

View File

@@ -7,6 +7,7 @@ import { useProjectHealth } from "../../hooks/useProjectHealth";
// Extended type with source node info for cross-node tests // Extended type with source node info for cross-node tests
interface ProjectInfoWithSource extends ProjectInfo { interface ProjectInfoWithSource extends ProjectInfo {
_sourceNodeName?: string; _sourceNodeName?: string;
nodeMappings?: Array<{ nodeId: string; path: string; available: boolean; nodeName?: string }>;
} }
// Default mock implementation // Default mock implementation
@@ -59,22 +60,21 @@ vi.mock("../ProjectCard", () => ({
ProjectCard: ({ ProjectCard: ({
project, project,
onSelect, onSelect,
node, availabilityMappings,
nodeNameFallback,
}: { }: {
project: ProjectInfo; project: ProjectInfo;
onSelect: (p: ProjectInfo) => void; onSelect: (p: ProjectInfo) => void;
node?: { id: string; name: string }; availabilityMappings?: Array<{ displayName: string; path: string }>;
nodeNameFallback?: string;
}) => ( }) => (
<div <div
data-testid={`project-card-${project.id}`} data-testid={`project-card-${project.id}`}
data-node={node?.name ?? nodeNameFallback ?? "none"} data-node={(availabilityMappings ?? []).map((mapping) => mapping.displayName).join(",") || "none"}
onClick={() => onSelect(project)} onClick={() => onSelect(project)}
> >
{project.name} {project.name}
{node && <span className="node-badge">{node.name}</span>} {(availabilityMappings ?? []).map((mapping) => (
{nodeNameFallback && !node && <span className="node-badge">{nodeNameFallback}</span>} <span key={`${mapping.displayName}-${mapping.path}`} className="node-badge">{mapping.displayName}</span>
))}
</div> </div>
), ),
})); }));
@@ -85,7 +85,7 @@ vi.mock("../ProjectGridSkeleton", () => ({
})); }));
function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource { function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
return { const project: ProjectInfoWithSource = {
id: "proj_abc123", id: "proj_abc123",
name: "Test Project", name: "Test Project",
path: "/home/user/projects/test", path: "/home/user/projects/test",
@@ -96,6 +96,12 @@ function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInf
lastActivityAt: new Date().toISOString(), lastActivityAt: new Date().toISOString(),
...overrides, ...overrides,
}; };
if (!project.nodeMappings && project.nodeId) {
project.nodeMappings = [{ nodeId: project.nodeId, path: project.path, available: true }];
}
return project;
} }
const noop = () => {}; const noop = () => {};

View File

@@ -1,446 +1,122 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { import { renderHook, act } from "@testing-library/react";
fetchProjects, import { useProjects } from "../useProjects";
registerProject, import * as api from "../../api";
unregisterProject, import type { ProjectInfoWithSource } from "../../api";
fetchProject,
updateProject,
detectProjects,
fetchProjectHealth,
fetchActivityFeed,
pauseProject,
resumeProject,
fetchFirstRunStatus,
fetchGlobalConcurrency,
fetchProjectTasks,
fetchProjectConfig,
type ProjectInfo,
type ProjectHealth,
type ActivityFeedEntry,
type FirstRunStatus,
type GlobalConcurrencyState,
type DetectedProject,
} from "../../api";
function mockFetchResponse( vi.mock("../../api", () => ({
ok: boolean, fetchProjectsAcrossNodes: vi.fn(),
body: unknown, registerProject: vi.fn(),
status = ok ? 200 : 500, updateProject: vi.fn(),
contentType = "application/json" unregisterProject: vi.fn(),
) { hasNodeMappingsSupport: vi.fn(),
const bodyText = JSON.stringify(body); }));
return Promise.resolve({
ok, const mockFetchProjectsAcrossNodes = vi.mocked(api.fetchProjectsAcrossNodes);
status, const mockRegisterProject = vi.mocked(api.registerProject);
statusText: ok ? "OK" : "Error", const mockUpdateProject = vi.mocked(api.updateProject);
headers: { const mockUnregisterProject = vi.mocked(api.unregisterProject);
get: (name: string) => const mockHasNodeMappingsSupport = vi.mocked(api.hasNodeMappingsSupport);
name.toLowerCase() === "content-type" ? contentType : null,
}, function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
json: () => Promise.resolve(body), return {
text: () => Promise.resolve(bodyText), id: "proj-1",
} as unknown as Response); 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("Project Management API", () => { async function flushPromises(): Promise<void> {
const originalFetch = globalThis.fetch; await Promise.resolve();
await Promise.resolve();
}
describe("useProjects", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true }); vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchProjectsAcrossNodes.mockReset();
mockRegisterProject.mockReset();
mockUpdateProject.mockReset();
mockUnregisterProject.mockReset();
mockHasNodeMappingsSupport.mockReset();
}); });
afterEach(() => { afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers(); vi.useRealTimers();
}); });
describe("fetchProjects", () => { it("normalizes mapping-enabled payloads into project.nodeMappings", async () => {
it("returns empty array when no projects", async () => { mockHasNodeMappingsSupport.mockReturnValue(true);
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [])); mockFetchProjectsAcrossNodes.mockResolvedValueOnce([
makeProject({
id: "proj-1",
nodeMappings: [{ nodeId: "node-a", path: "/mnt/a", available: true }],
}),
makeProject({
id: "proj-2",
pathMappings: [{ nodeId: "node-b", path: "/mnt/b", available: false }],
}),
]);
const result = await fetchProjects(); const { result } = renderHook(() => useProjects());
expect(result).toEqual([]); await act(async () => {
expect(globalThis.fetch).toHaveBeenCalledWith( await flushPromises();
"/api/projects",
expect.any(Object)
);
}); });
it("returns projects list when available", async () => { expect(result.current.loading).toBe(false);
const mockProjects: ProjectInfo[] = [ expect(result.current.projects[0].nodeMappings).toEqual([
{ { nodeId: "node-a", path: "/mnt/a", available: true, nodeName: undefined },
id: "proj_123", ]);
name: "Test Project", expect(result.current.projects[1].nodeMappings).toEqual([
path: "/test/path", { nodeId: "node-b", path: "/mnt/b", available: false, nodeName: undefined },
status: "active", ]);
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProjects));
const result = await fetchProjects();
expect(result).toHaveLength(1);
expect(result[0].id).toBe("proj_123");
expect(result[0].name).toBe("Test Project");
});
}); });
describe("registerProject", () => { it("synthesizes a legacy fallback mapping from nodeId + path", async () => {
it("registers a new project with valid input", async () => { mockHasNodeMappingsSupport.mockReturnValue(false);
const mockProject: ProjectInfo = { mockFetchProjectsAcrossNodes.mockResolvedValueOnce([
id: "proj_new", makeProject({ id: "proj-legacy", nodeId: "node-legacy", _sourceNodeName: "Legacy Node" }),
name: "New Project", ]);
path: "/absolute/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
const result = await registerProject({ const { result } = renderHook(() => useProjects());
name: "New Project",
path: "/absolute/path",
isolationMode: "in-process",
});
expect(result.id).toBe("proj_new"); await act(async () => {
expect(result.name).toBe("New Project"); await flushPromises();
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects",
expect.objectContaining({
method: "POST",
body: expect.any(String),
})
);
}); });
expect(result.current.projects[0].nodeMappings).toEqual([
{
nodeId: "node-legacy",
nodeName: "Legacy Node",
path: "/workspace/project-one",
available: true,
},
]);
}); });
describe("unregisterProject", () => { it("refreshes projects using the same normalization", async () => {
it("unregisters a project", async () => { mockHasNodeMappingsSupport.mockReturnValue(false);
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {})); mockFetchProjectsAcrossNodes
.mockResolvedValueOnce([makeProject({ id: "proj-1", nodeId: "node-a" })])
.mockResolvedValueOnce([makeProject({ id: "proj-2", nodeId: "node-b" })]);
await unregisterProject("proj_test123"); const { result } = renderHook(() => useProjects());
expect(globalThis.fetch).toHaveBeenCalledWith( await act(async () => {
"/api/projects/proj_test123", await flushPromises();
expect.objectContaining({
method: "DELETE",
})
);
});
});
describe("fetchProjectHealth", () => {
it("returns health metrics for a project", async () => {
const mockHealth: ProjectHealth = {
projectId: "proj_test123",
status: "active",
activeTaskCount: 5,
inFlightAgentCount: 2,
totalTasksCompleted: 10,
totalTasksFailed: 1,
updatedAt: "2026-01-01T00:00:00.000Z",
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockHealth));
const result = await fetchProjectHealth("proj_test123");
expect(result.projectId).toBe("proj_test123");
expect(result.activeTaskCount).toBe(5);
expect(result.totalTasksCompleted).toBe(10);
});
});
describe("fetchActivityFeed", () => {
it("returns activity feed entries", async () => {
const mockEntries: ActivityFeedEntry[] = [
{
id: "entry_1",
timestamp: "2026-01-01T00:00:00.000Z",
type: "task:created",
projectId: "proj_123",
projectName: "Test Project",
taskId: "FN-001",
taskTitle: "Test Task",
details: "Task created",
},
];
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockEntries));
const result = await fetchActivityFeed();
expect(result).toHaveLength(1);
expect(result[0].type).toBe("task:created");
expect(result[0].projectName).toBe("Test Project");
}); });
it("supports limit parameter", async () => { await act(async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [])); await result.current.refresh();
await fetchActivityFeed({ limit: 10 });
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining("limit=10"),
expect.any(Object)
);
}); });
it("supports projectId filter", async () => { expect(result.current.projects[0].id).toBe("proj-2");
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [])); expect(result.current.projects[0].nodeMappings?.[0]?.nodeId).toBe("node-b");
await fetchActivityFeed({ projectId: "proj_123" });
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining("projectId=proj_123"),
expect.any(Object)
);
});
});
describe("fetchFirstRunStatus", () => {
it("returns first run status", async () => {
const mockStatus: FirstRunStatus = {
hasProjects: false,
singleProjectPath: null,
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockStatus));
const result = await fetchFirstRunStatus();
expect(result.hasProjects).toBe(false);
expect(result.singleProjectPath).toBeNull();
});
it("returns single project path when only one project", async () => {
const mockStatus: FirstRunStatus = {
hasProjects: true,
singleProjectPath: "/projects/my-project",
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockStatus));
const result = await fetchFirstRunStatus();
expect(result.hasProjects).toBe(true);
expect(result.singleProjectPath).toBe("/projects/my-project");
});
});
describe("fetchGlobalConcurrency", () => {
it("returns global concurrency state", async () => {
const mockState: GlobalConcurrencyState = {
globalMaxConcurrent: 4,
currentlyActive: 2,
queuedCount: 0,
projectsActive: { "proj_123": 2 },
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockState));
const result = await fetchGlobalConcurrency();
expect(result.globalMaxConcurrent).toBe(4);
expect(result.currentlyActive).toBe(2);
expect(result.projectsActive["proj_123"]).toBe(2);
});
});
describe("pauseProject", () => {
it("pauses a project", async () => {
const mockProject: ProjectInfo = {
id: "proj_123",
name: "Test Project",
path: "/test/path",
status: "paused",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
const result = await pauseProject("proj_123");
expect(result.status).toBe("paused");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/proj_123/pause",
expect.objectContaining({
method: "POST",
})
);
});
});
describe("resumeProject", () => {
it("resumes a paused project", async () => {
const mockProject: ProjectInfo = {
id: "proj_123",
name: "Test Project",
path: "/test/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
const result = await resumeProject("proj_123");
expect(result.status).toBe("active");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/proj_123/resume",
expect.objectContaining({
method: "POST",
})
);
});
});
describe("fetchProjectTasks", () => {
it("fetches tasks for a specific project", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
await fetchProjectTasks("proj_123");
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining("projectId=proj_123"),
expect.any(Object)
);
});
it("supports pagination", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
await fetchProjectTasks("proj_123", 10, 20);
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining("limit=10"),
expect.any(Object)
);
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining("offset=20"),
expect.any(Object)
);
});
});
describe("fetchProjectConfig", () => {
it("fetches project config", async () => {
const mockConfig = { maxConcurrent: 4, rootDir: "/projects/test" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockConfig));
const result = await fetchProjectConfig("proj_123");
expect(result.maxConcurrent).toBe(4);
expect(result.rootDir).toBe("/projects/test");
});
});
describe("fetchProject (single)", () => {
it("fetches a specific project by ID", async () => {
const mockProject: ProjectInfo = {
id: "proj_123",
name: "Specific Project",
path: "/specific/path",
status: "active",
isolationMode: "child-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
const result = await fetchProject("proj_123");
expect(result.id).toBe("proj_123");
expect(result.name).toBe("Specific Project");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/proj_123",
expect.any(Object)
);
});
});
describe("updateProject", () => {
it("updates project with valid data", async () => {
const mockProject: ProjectInfo = {
id: "proj_123",
name: "Updated Name",
path: "/test/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
const result = await updateProject("proj_123", { name: "Updated Name" });
expect(result.name).toBe("Updated Name");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/proj_123",
expect.objectContaining({
method: "PATCH",
body: expect.any(String),
})
);
});
it("updates project isolationMode", async () => {
const mockProject: ProjectInfo = {
id: "proj_123",
name: "Test Project",
path: "/test/path",
status: "active",
isolationMode: "child-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
const result = await updateProject("proj_123", { isolationMode: "child-process" });
expect(result.isolationMode).toBe("child-process");
});
});
describe("detectProjects", () => {
it("auto-detects projects in a base path", async () => {
const mockDetected = {
projects: [
{ path: "/home/user/project1", suggestedName: "project1", existing: false },
{ path: "/home/user/project2", suggestedName: "project2", existing: true },
],
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockDetected));
const result = await detectProjects("/home/user");
expect(result.projects).toHaveLength(2);
expect(result.projects[0].path).toBe("/home/user/project1");
expect(result.projects[0].suggestedName).toBe("project1");
expect(result.projects[1].existing).toBe(true);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/detect",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ basePath: "/home/user" }),
})
);
});
it("uses home directory when basePath not provided", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { projects: [] }));
await detectProjects();
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/detect",
expect.objectContaining({
body: JSON.stringify({ basePath: undefined }),
})
);
});
}); });
}); });

View File

@@ -2,11 +2,13 @@ import { useState, useEffect, useCallback, useRef } from "react";
import type { ProjectInfo } from "../api"; import type { ProjectInfo } from "../api";
import { import {
fetchProjectsAcrossNodes, fetchProjectsAcrossNodes,
hasNodeMappingsSupport,
registerProject, registerProject,
unregisterProject, unregisterProject,
updateProject, updateProject,
type ProjectCreateInput, type ProjectCreateInput,
type ProjectInfoWithSource, type ProjectInfoWithSource,
type ProjectNodeAvailability,
} from "../api"; } from "../api";
export interface UseProjectsResult { export interface UseProjectsResult {
@@ -29,6 +31,43 @@ export interface UseProjectsResult {
const POLL_INTERVAL_MS = 5000; // 5 seconds const POLL_INTERVAL_MS = 5000; // 5 seconds
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000; const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
function normalizeNodeMappings(project: ProjectInfoWithSource): ProjectNodeAvailability[] {
const mappingSource = hasNodeMappingsSupport(project)
? (project.nodeMappings ?? project.projectNodeMappings ?? project.pathMappings ?? [])
: [];
const normalizedMappings = mappingSource
.filter((mapping) => Boolean(mapping?.nodeId) && Boolean(mapping?.path))
.map((mapping) => ({
nodeId: mapping.nodeId,
nodeName: mapping.nodeName,
path: mapping.path,
available: mapping.available !== false,
}));
if (normalizedMappings.length > 0) {
return normalizedMappings;
}
if (project.nodeId && project.path) {
return [{
nodeId: project.nodeId,
nodeName: project._sourceNodeName,
path: project.path,
available: true,
}];
}
return [];
}
function normalizeProjects(projects: ProjectInfoWithSource[]): ProjectInfoWithSource[] {
return projects.map((project) => ({
...project,
nodeMappings: normalizeNodeMappings(project),
}));
}
/** /**
* Hook for fetching and managing projects. * Hook for fetching and managing projects.
* Automatically polls for updates every 5 seconds. * Automatically polls for updates every 5 seconds.
@@ -46,7 +85,7 @@ export function useProjects(): UseProjectsResult {
try { try {
setError(null); setError(null);
const data = await fetchProjectsAcrossNodes(); const data = await fetchProjectsAcrossNodes();
setProjects(data); setProjects(normalizeProjects(data));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch projects"); setError(err instanceof Error ? err.message : "Failed to fetch projects");
// Don't clear existing projects on error - keep showing stale data // Don't clear existing projects on error - keep showing stale data
@@ -62,10 +101,11 @@ export function useProjects(): UseProjectsResult {
const t0 = performance.now(); const t0 = performance.now();
try { try {
const data = await fetchProjectsAcrossNodes(); const data = await fetchProjectsAcrossNodes();
const normalizedData = normalizeProjects(data);
const elapsed = Math.round(performance.now() - t0); const elapsed = Math.round(performance.now() - t0);
console.log(`[useProjects] initial fetchProjectsAcrossNodes took ${elapsed}ms (${data.length} projects)`); console.log(`[useProjects] initial fetchProjectsAcrossNodes took ${elapsed}ms (${normalizedData.length} projects)`);
if (!cancelled) { if (!cancelled) {
setProjects(data); setProjects(normalizedData);
setError(null); setError(null);
} }
} catch (err) { } catch (err) {

View File

@@ -1,16 +1,19 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { import {
isProjectRoutedToNode, getAvailableNodeMappingsForNode,
getProjectsForNode, getNodeMappingsForProject,
getProjectCountForNode, getProjectCountForNode,
getProjectsForNode,
getUnassignedProjectCount, getUnassignedProjectCount,
isProjectAvailableOnNode,
resolveNodeDisplayName,
} from "../nodeProjectAssignment"; } from "../nodeProjectAssignment";
import type { NodeInfo, ProjectInfo } from "../../api"; import type { NodeInfo, ProjectInfoWithSource } from "../../api";
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo { function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return { return {
id: "node-1", id: "node-1",
name: "Test Node", name: "Node One",
type: "local", type: "local",
status: "online", status: "online",
maxConcurrent: 2, maxConcurrent: 2,
@@ -20,7 +23,7 @@ function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
}; };
} }
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo { function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
return { return {
id: "proj-1", id: "proj-1",
name: "Project One", name: "Project One",
@@ -29,155 +32,68 @@ function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
isolationMode: "in-process", isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
nodeMappings: [],
...overrides, ...overrides,
}; };
} }
describe("nodeProjectAssignment", () => { describe("nodeProjectAssignment", () => {
describe("isProjectRoutedToNode", () => { it("normalizes mapping entries and defaults available=true", () => {
describe("local node", () => { const project = makeProject({
const localNode = makeNode({ id: "local-1", type: "local" }); nodeMappings: [
{ nodeId: "node-1", path: "/mnt/one", available: true },
it("returns true for projects explicitly assigned to this local node", () => { { nodeId: "node-2", path: "/mnt/two", available: false },
const project = makeProject({ id: "proj-1", nodeId: "local-1" }); { nodeId: "", path: "/bad", available: true },
expect(isProjectRoutedToNode(project, localNode)).toBe(true); ],
});
it("returns true for unassigned projects (nodeId undefined)", () => {
const project = makeProject({ id: "proj-1", nodeId: undefined });
expect(isProjectRoutedToNode(project, localNode)).toBe(true);
});
it("returns true for unassigned projects (nodeId null)", () => {
const project = makeProject({ id: "proj-1", nodeId: null as unknown as string });
expect(isProjectRoutedToNode(project, localNode)).toBe(true);
});
it("returns false for projects assigned to other nodes", () => {
const project = makeProject({ id: "proj-1", nodeId: "other-node" });
expect(isProjectRoutedToNode(project, localNode)).toBe(false);
});
it("returns false for projects assigned to remote nodes", () => {
const project = makeProject({ id: "proj-1", nodeId: "remote-1" });
expect(isProjectRoutedToNode(project, localNode)).toBe(false);
});
}); });
describe("remote node", () => { expect(getNodeMappingsForProject(project)).toEqual([
const remoteNode = makeNode({ id: "remote-1", type: "remote" }); { nodeId: "node-1", path: "/mnt/one", available: true, nodeName: undefined },
{ nodeId: "node-2", path: "/mnt/two", available: false, nodeName: undefined },
it("returns true for projects explicitly assigned to this remote node", () => { ]);
const project = makeProject({ id: "proj-1", nodeId: "remote-1" });
expect(isProjectRoutedToNode(project, remoteNode)).toBe(true);
});
it("returns false for unassigned projects (nodeId undefined)", () => {
const project = makeProject({ id: "proj-1", nodeId: undefined });
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
});
it("returns false for unassigned projects (nodeId null)", () => {
const project = makeProject({ id: "proj-1", nodeId: null as unknown as string });
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
});
it("returns false for projects assigned to local nodes", () => {
const project = makeProject({ id: "proj-1", nodeId: "local-1" });
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
});
it("returns false for projects assigned to other remote nodes", () => {
const project = makeProject({ id: "proj-1", nodeId: "other-remote" });
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
});
});
}); });
describe("getProjectsForNode", () => { it("filters available mappings for a given node", () => {
it("returns all projects routed to a local node (including unassigned)", () => { const project = makeProject({
const localNode = makeNode({ id: "local-1", type: "local" }); nodeMappings: [
const projects: ProjectInfo[] = [ { nodeId: "node-1", path: "/mnt/live", available: true },
makeProject({ id: "proj-1", nodeId: "local-1" }), // assigned to this local node { nodeId: "node-1", path: "/mnt/down", available: false },
makeProject({ id: "proj-2", nodeId: undefined }), // unassigned ],
makeProject({ id: "proj-3", nodeId: "other-local" }), // assigned to different local node
makeProject({ id: "proj-4", nodeId: "remote-1" }), // assigned to remote
];
const result = getProjectsForNode(projects, localNode);
expect(result.map((p) => p.id)).toEqual(["proj-1", "proj-2"]);
}); });
const node = makeNode({ id: "node-1" });
it("returns only explicitly assigned projects for a remote node", () => { expect(getAvailableNodeMappingsForNode(project, node)).toEqual([
const remoteNode = makeNode({ id: "remote-1", type: "remote" }); { nodeId: "node-1", path: "/mnt/live", available: true, nodeName: undefined },
const projects: ProjectInfo[] = [ ]);
makeProject({ id: "proj-1", nodeId: "remote-1" }), // assigned to this remote node expect(isProjectAvailableOnNode(project, node)).toBe(true);
makeProject({ id: "proj-2", nodeId: undefined }), // unassigned
makeProject({ id: "proj-3", nodeId: "local-1" }), // assigned to local
makeProject({ id: "proj-4", nodeId: "other-remote" }), // assigned to other remote
];
const result = getProjectsForNode(projects, remoteNode);
expect(result.map((p) => p.id)).toEqual(["proj-1"]);
});
}); });
describe("getProjectCountForNode", () => { it("builds project lists and counts from available mappings only", () => {
it("returns correct count for local node (includes unassigned)", () => { const node = makeNode({ id: "node-1" });
const localNode = makeNode({ id: "local-1", type: "local" }); const projects = [
const projects: ProjectInfo[] = [ makeProject({ id: "proj-a", nodeMappings: [{ nodeId: "node-1", path: "/a", available: true }] }),
makeProject({ id: "proj-1", nodeId: "local-1" }), makeProject({ id: "proj-b", nodeMappings: [{ nodeId: "node-1", path: "/b", available: false }] }),
makeProject({ id: "proj-2", nodeId: undefined }), makeProject({ id: "proj-c", nodeMappings: [{ nodeId: "node-2", path: "/c", available: true }] }),
makeProject({ id: "proj-3", nodeId: undefined }), ];
];
expect(getProjectCountForNode(projects, localNode)).toBe(3); expect(getProjectsForNode(projects, node).map((project) => project.id)).toEqual(["proj-a"]);
}); expect(getProjectCountForNode(projects, node)).toBe(1);
it("returns correct count for remote node (explicit only)", () => {
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
const projects: ProjectInfo[] = [
makeProject({ id: "proj-1", nodeId: "remote-1" }),
makeProject({ id: "proj-2", nodeId: "remote-1" }),
makeProject({ id: "proj-3", nodeId: undefined }),
];
expect(getProjectCountForNode(projects, remoteNode)).toBe(2);
});
it("returns 0 when no projects are routed to the node", () => {
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
const projects: ProjectInfo[] = [
makeProject({ id: "proj-1", nodeId: "local-1" }),
makeProject({ id: "proj-2", nodeId: undefined }),
];
expect(getProjectCountForNode(projects, remoteNode)).toBe(0);
});
}); });
describe("getUnassignedProjectCount", () => { it("resolves display names in canonical order", () => {
it("counts projects without nodeId", () => { const nodes = [makeNode({ id: "node-1", name: "Primary Node" })];
const projects: ProjectInfo[] = [ const project = makeProject({ _sourceNodeName: "Source Node" });
makeProject({ id: "proj-1", nodeId: undefined }),
makeProject({ id: "proj-2", nodeId: null as unknown as string }),
makeProject({ id: "proj-3", nodeId: "local-1" }),
];
expect(getUnassignedProjectCount(projects)).toBe(2); expect(resolveNodeDisplayName("node-1", { nodeId: "node-1", path: "/a", available: true, nodeName: "Mapping Name" }, nodes, project)).toBe("Primary Node");
}); expect(resolveNodeDisplayName("node-2", { nodeId: "node-2", path: "/b", available: true, nodeName: "Mapping Name" }, nodes, project)).toBe("Mapping Name");
expect(resolveNodeDisplayName("node-3", undefined, nodes, project)).toBe("Source Node");
expect(resolveNodeDisplayName("node-4", undefined, nodes, makeProject({ _sourceNodeName: undefined }))).toBe("node-4");
});
it("returns 0 when all projects are assigned", () => { it("counts unassigned projects as projects without mappings", () => {
const projects: ProjectInfo[] = [ expect(getUnassignedProjectCount([
makeProject({ id: "proj-1", nodeId: "local-1" }), makeProject({ id: "proj-a", nodeMappings: [] }),
makeProject({ id: "proj-2", nodeId: "remote-1" }), makeProject({ id: "proj-b", nodeMappings: [{ nodeId: "node-1", path: "/b", available: true }] }),
]; ])).toBe(1);
expect(getUnassignedProjectCount(projects)).toBe(0);
});
it("returns 0 for empty array", () => {
expect(getUnassignedProjectCount([])).toBe(0);
});
}); });
}); });

View File

@@ -1,75 +1,55 @@
/** import type { NodeInfo, ProjectInfoWithSource, ProjectNodeAvailability } from "../api";
* Node-Project Assignment Utilities
*
* Provides canonical counting logic for projects routed to a node.
*
* **Runtime Behavior:**
* - Projects with `nodeId` pointing to a remote node → run on that remote node
* - Projects with `nodeId` pointing to a local node → run on that local node
* - Projects without `nodeId` (unassigned) → run on local in-process runtime
*
* **Counting Rules:**
* - Local nodes: include both explicitly-assigned projects AND unassigned projects
* - Remote nodes: include only explicitly-assigned projects
*/
import type { NodeInfo, ProjectInfo } from "../api"; function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
/**
* Check if a project is routed to a specific node based on runtime rules.
*
* @param project - The project to check
* @param node - The node to check against
* @returns true if the project runs on this node
*/
export function isProjectRoutedToNode(project: ProjectInfo, node: NodeInfo): boolean {
if (node.type === "remote") {
// Remote nodes: only explicit assignment counts
return project.nodeId === node.id;
}
// Local nodes: explicit assignment OR unassigned (null/undefined)
if (project.nodeId === node.id) {
return true;
}
// Unassigned projects run on local in-process runtime
if (project.nodeId === undefined || project.nodeId === null) {
return true;
}
return false;
} }
/** export function getNodeMappingsForProject(project: ProjectInfoWithSource): ProjectNodeAvailability[] {
* Get all projects that are routed to a specific node. const mappings = project.nodeMappings ?? [];
* return mappings
* @param projects - All projects .filter((mapping) => isNonEmptyString(mapping.nodeId) && isNonEmptyString(mapping.path))
* @param node - The node to filter by .map((mapping) => ({
* @returns Projects routed to this node nodeId: mapping.nodeId,
*/ nodeName: mapping.nodeName,
export function getProjectsForNode(projects: ProjectInfo[], node: NodeInfo): ProjectInfo[] { path: mapping.path,
return projects.filter((project) => isProjectRoutedToNode(project, node)); available: mapping.available !== false,
}));
} }
/** export function getAvailableNodeMappingsForNode(
* Get the count of projects routed to a specific node. project: ProjectInfoWithSource,
* node: NodeInfo,
* @param projects - All projects ): ProjectNodeAvailability[] {
* @param node - The node to count projects for return getNodeMappingsForProject(project).filter(
* @returns Number of projects on this node (mapping) => mapping.nodeId === node.id && mapping.available,
*/ );
export function getProjectCountForNode(projects: ProjectInfo[], node: NodeInfo): number { }
export function isProjectAvailableOnNode(project: ProjectInfoWithSource, node: NodeInfo): boolean {
return getAvailableNodeMappingsForNode(project, node).length > 0;
}
export function getProjectsForNode(projects: ProjectInfoWithSource[], node: NodeInfo): ProjectInfoWithSource[] {
return projects.filter((project) => isProjectAvailableOnNode(project, node));
}
export function getProjectCountForNode(projects: ProjectInfoWithSource[], node: NodeInfo): number {
return getProjectsForNode(projects, node).length; return getProjectsForNode(projects, node).length;
} }
/** export function resolveNodeDisplayName(
* Get the count of unassigned projects (projects without nodeId). nodeId: string,
* These projects run on the local in-process runtime. mapping: ProjectNodeAvailability | undefined,
* nodes: NodeInfo[],
* @param projects - All projects project: ProjectInfoWithSource,
* @returns Number of unassigned projects ): string {
*/ const nodeMatch = nodes.find((node) => node.id === nodeId);
export function getUnassignedProjectCount(projects: ProjectInfo[]): number { if (nodeMatch?.name) return nodeMatch.name;
return projects.filter((project) => project.nodeId === undefined || project.nodeId === null).length; if (mapping?.nodeName) return mapping.nodeName;
if (project._sourceNodeName) return project._sourceNodeName;
return nodeId;
}
export function getUnassignedProjectCount(projects: ProjectInfoWithSource[]): number {
return projects.filter((project) => getNodeMappingsForProject(project).length === 0).length;
} }