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 |
| 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
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");
}
/** 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 {
/** Name of the source node (added by server for remote projects) */
/** Name of the source node (added by server for remote projects). */
_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) */

View File

@@ -189,17 +189,17 @@
}
.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);
}
.project-card-action-pause {
color: var(--warning, #e3b341);
color: var(--color-warning);
}
.project-card-action-pause:hover:not(:disabled) {
background: rgba(227, 179, 65, 0.1);
border-color: var(--warning, #e3b341);
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
border-color: var(--color-warning);
}
.project-card-action-open {
@@ -207,7 +207,7 @@
}
.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);
}
@@ -217,10 +217,59 @@
}
.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);
}
.project-card-action-remove.is-armed {
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 "./ProjectCard.css";
import type { RegisteredProject, ProjectHealth, ProjectStatus } from "@fusion/core";
import type { NodeInfo } from "../api";
import type { ProjectNodeAvailability } from "../api";
export interface ProjectCardProps {
project: RegisteredProject;
@@ -11,17 +11,15 @@ export interface ProjectCardProps {
onPause: (project: RegisteredProject) => void;
onResume: (project: RegisteredProject) => void;
onRemove: (project: RegisteredProject) => void;
node?: NodeInfo;
/** Fallback node name when the node object is not available (e.g., for remote projects) */
nodeNameFallback?: string;
availabilityMappings?: Array<ProjectNodeAvailability & { displayName: string }>;
isLoading?: boolean;
}
const STATUS_CONFIG: Record<ProjectStatus, { label: string; color: string; icon: typeof Play }> = {
active: { label: "Active", color: "var(--success)", icon: Play },
paused: { label: "Paused", color: "var(--warning)", icon: Pause },
active: { label: "Active", color: "var(--color-success)", icon: Play },
paused: { label: "Paused", color: "var(--color-warning)", icon: Pause },
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 {
@@ -72,21 +70,18 @@ function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardP
return false;
}
const prevNode = previous.node;
const nextNode = next.node;
if (!prevNode && !nextNode) {
// Compare fallback names
if (previous.nodeNameFallback !== next.nodeNameFallback) return false;
return true;
}
if (!prevNode || !nextNode) return false;
const prevMappings = previous.availabilityMappings ?? [];
const nextMappings = next.availabilityMappings ?? [];
if (prevMappings.length !== nextMappings.length) return false;
return (
prevNode.id === nextNode.id
&& prevNode.name === nextNode.name
&& prevNode.status === nextNode.status
&& prevNode.type === nextNode.type
);
return prevMappings.every((mapping, index) => {
const nextMapping = nextMappings[index];
return Boolean(nextMapping)
&& mapping.nodeId === nextMapping.nodeId
&& mapping.path === nextMapping.path
&& mapping.displayName === nextMapping.displayName
&& mapping.available === nextMapping.available;
});
}
function ProjectCardInner({
@@ -96,8 +91,7 @@ function ProjectCardInner({
onPause,
onResume,
onRemove,
node,
nodeNameFallback,
availabilityMappings = [],
isLoading = false,
}: ProjectCardProps) {
const [removeArmed, setRemoveArmed] = useState(false);
@@ -155,10 +149,19 @@ function ProjectCardInner({
<h3 className="project-card-name" title={project.name}>
{project.name}
</h3>
{(node || nodeNameFallback) && (
<span className="node-badge" title={`Assigned node: ${node?.name ?? nodeNameFallback}`}>
on: {node?.name ?? nodeNameFallback}
</span>
{availabilityMappings.length > 0 && (
<div className="project-card-availability" aria-label="Project node availability">
{availabilityMappings.slice(0, 3).map((mapping) => (
<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}>
{truncatePath(project.path)}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { NodesView } from "../NodesView";
import type { NodeInfo, ProjectInfo } from "../../api";
import type { NodeInfo, ProjectInfoWithSource } from "../../api";
import { useNodes } from "../../hooks/useNodes";
import { useProjects } from "../../hooks/useProjects";
import { useNodeSettingsSync } from "../../hooks/useNodeSettingsSync";
@@ -62,8 +62,8 @@ function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
};
}
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
return {
function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
const project: ProjectInfoWithSource = {
id: "proj-1",
name: "Project One",
path: "/workspace/project-one",
@@ -73,6 +73,12 @@ function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
updatedAt: "2026-01-01T00:00:00.000Z",
...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> {
@@ -274,11 +280,11 @@ describe("NodesView", () => {
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({
projects: [
makeProject({ id: "proj-1", nodeId: "node-1" }), // explicitly assigned
makeProject({ id: "proj-2", nodeId: undefined }), // unassigned - runs on local
makeProject({ id: "proj-1", nodeId: "node-1" }),
makeProject({ id: "proj-2", nodeMappings: [{ nodeId: "node-1", path: "/workspace/project-two", available: false }] }),
],
loading: false,
error: null,
@@ -299,8 +305,7 @@ describe("NodesView", () => {
expect(nodeCard).toBeInTheDocument();
fireEvent.click(nodeCard!);
// Modal should show "Projects (2)" - including the unassigned project
expect(screen.getByText("Projects (2)")).toBeDefined();
expect(screen.getByText("Projects (1)")).toBeDefined();
});
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 { 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", () => ({
@@ -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 = () => {};
describe("ProjectCard", () => {
@@ -74,12 +60,12 @@ describe("ProjectCard", () => {
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(
<ProjectCard
project={makeProject()}
health={makeHealth()}
node={makeNode({ name: "Remote Worker" })}
availabilityMappings={[{ nodeId: "node-1", displayName: "Remote Worker", path: "/srv/work", available: true }]}
onSelect={noop}
onPause={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", () => {

View File

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

View File

@@ -1,446 +1,122 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
fetchProjects,
registerProject,
unregisterProject,
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";
import { renderHook, act } from "@testing-library/react";
import { useProjects } from "../useProjects";
import * as api from "../../api";
import type { ProjectInfoWithSource } from "../../api";
function mockFetchResponse(
ok: boolean,
body: unknown,
status = ok ? 200 : 500,
contentType = "application/json"
) {
const bodyText = JSON.stringify(body);
return Promise.resolve({
ok,
status,
statusText: ok ? "OK" : "Error",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? contentType : null,
},
json: () => Promise.resolve(body),
text: () => Promise.resolve(bodyText),
} as unknown as Response);
vi.mock("../../api", () => ({
fetchProjectsAcrossNodes: vi.fn(),
registerProject: vi.fn(),
updateProject: vi.fn(),
unregisterProject: vi.fn(),
hasNodeMappingsSupport: vi.fn(),
}));
const mockFetchProjectsAcrossNodes = vi.mocked(api.fetchProjectsAcrossNodes);
const mockRegisterProject = vi.mocked(api.registerProject);
const mockUpdateProject = vi.mocked(api.updateProject);
const mockUnregisterProject = vi.mocked(api.unregisterProject);
const mockHasNodeMappingsSupport = vi.mocked(api.hasNodeMappingsSupport);
function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
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("Project Management API", () => {
const originalFetch = globalThis.fetch;
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("useProjects", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchProjectsAcrossNodes.mockReset();
mockRegisterProject.mockReset();
mockUpdateProject.mockReset();
mockUnregisterProject.mockReset();
mockHasNodeMappingsSupport.mockReset();
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
describe("fetchProjects", () => {
it("returns empty array when no projects", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
it("normalizes mapping-enabled payloads into project.nodeMappings", async () => {
mockHasNodeMappingsSupport.mockReturnValue(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([]);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects",
expect.any(Object)
);
await act(async () => {
await flushPromises();
});
it("returns projects list when available", async () => {
const mockProjects: 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, mockProjects));
const result = await fetchProjects();
expect(result).toHaveLength(1);
expect(result[0].id).toBe("proj_123");
expect(result[0].name).toBe("Test Project");
});
expect(result.current.loading).toBe(false);
expect(result.current.projects[0].nodeMappings).toEqual([
{ nodeId: "node-a", path: "/mnt/a", available: true, nodeName: undefined },
]);
expect(result.current.projects[1].nodeMappings).toEqual([
{ nodeId: "node-b", path: "/mnt/b", available: false, nodeName: undefined },
]);
});
describe("registerProject", () => {
it("registers a new project with valid input", async () => {
const mockProject: ProjectInfo = {
id: "proj_new",
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));
it("synthesizes a legacy fallback mapping from nodeId + path", async () => {
mockHasNodeMappingsSupport.mockReturnValue(false);
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([
makeProject({ id: "proj-legacy", nodeId: "node-legacy", _sourceNodeName: "Legacy Node" }),
]);
const result = await registerProject({
name: "New Project",
path: "/absolute/path",
isolationMode: "in-process",
});
const { result } = renderHook(() => useProjects());
expect(result.id).toBe("proj_new");
expect(result.name).toBe("New Project");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects",
expect.objectContaining({
method: "POST",
body: expect.any(String),
})
);
await act(async () => {
await flushPromises();
});
expect(result.current.projects[0].nodeMappings).toEqual([
{
nodeId: "node-legacy",
nodeName: "Legacy Node",
path: "/workspace/project-one",
available: true,
},
]);
});
describe("unregisterProject", () => {
it("unregisters a project", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
it("refreshes projects using the same normalization", async () => {
mockHasNodeMappingsSupport.mockReturnValue(false);
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(
"/api/projects/proj_test123",
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");
await act(async () => {
await flushPromises();
});
it("supports limit parameter", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
await fetchActivityFeed({ limit: 10 });
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining("limit=10"),
expect.any(Object)
);
await act(async () => {
await result.current.refresh();
});
it("supports projectId filter", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
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 }),
})
);
});
expect(result.current.projects[0].id).toBe("proj-2");
expect(result.current.projects[0].nodeMappings?.[0]?.nodeId).toBe("node-b");
});
});

View File

@@ -2,11 +2,13 @@ import { useState, useEffect, useCallback, useRef } from "react";
import type { ProjectInfo } from "../api";
import {
fetchProjectsAcrossNodes,
hasNodeMappingsSupport,
registerProject,
unregisterProject,
updateProject,
type ProjectCreateInput,
type ProjectInfoWithSource,
type ProjectNodeAvailability,
} from "../api";
export interface UseProjectsResult {
@@ -29,6 +31,43 @@ export interface UseProjectsResult {
const POLL_INTERVAL_MS = 5000; // 5 seconds
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.
* Automatically polls for updates every 5 seconds.
@@ -46,7 +85,7 @@ export function useProjects(): UseProjectsResult {
try {
setError(null);
const data = await fetchProjectsAcrossNodes();
setProjects(data);
setProjects(normalizeProjects(data));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch projects");
// Don't clear existing projects on error - keep showing stale data
@@ -62,10 +101,11 @@ export function useProjects(): UseProjectsResult {
const t0 = performance.now();
try {
const data = await fetchProjectsAcrossNodes();
const normalizedData = normalizeProjects(data);
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) {
setProjects(data);
setProjects(normalizedData);
setError(null);
}
} catch (err) {

View File

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

View File

@@ -1,75 +1,55 @@
/**
* 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, ProjectInfoWithSource, ProjectNodeAvailability } from "../api";
import type { NodeInfo, ProjectInfo } from "../api";
/**
* 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;
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/**
* Get all projects that are routed to a specific node.
*
* @param projects - All projects
* @param node - The node to filter by
* @returns Projects routed to this node
*/
export function getProjectsForNode(projects: ProjectInfo[], node: NodeInfo): ProjectInfo[] {
return projects.filter((project) => isProjectRoutedToNode(project, node));
export function getNodeMappingsForProject(project: ProjectInfoWithSource): ProjectNodeAvailability[] {
const mappings = project.nodeMappings ?? [];
return mappings
.filter((mapping) => isNonEmptyString(mapping.nodeId) && isNonEmptyString(mapping.path))
.map((mapping) => ({
nodeId: mapping.nodeId,
nodeName: mapping.nodeName,
path: mapping.path,
available: mapping.available !== false,
}));
}
/**
* Get the count of projects routed to a specific node.
*
* @param projects - All projects
* @param node - The node to count projects for
* @returns Number of projects on this node
*/
export function getProjectCountForNode(projects: ProjectInfo[], node: NodeInfo): number {
export function getAvailableNodeMappingsForNode(
project: ProjectInfoWithSource,
node: NodeInfo,
): ProjectNodeAvailability[] {
return getNodeMappingsForProject(project).filter(
(mapping) => mapping.nodeId === node.id && mapping.available,
);
}
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;
}
/**
* Get the count of unassigned projects (projects without nodeId).
* These projects run on the local in-process runtime.
*
* @param projects - All projects
* @returns Number of unassigned projects
*/
export function getUnassignedProjectCount(projects: ProjectInfo[]): number {
return projects.filter((project) => project.nodeId === undefined || project.nodeId === null).length;
export function resolveNodeDisplayName(
nodeId: string,
mapping: ProjectNodeAvailability | undefined,
nodes: NodeInfo[],
project: ProjectInfoWithSource,
): string {
const nodeMatch = nodes.find((node) => node.id === nodeId);
if (nodeMatch?.name) return nodeMatch.name;
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;
}