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:
@@ -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.
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user