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 |
|
| Offline | Red | Node is unreachable or shut down |
|
||||||
| Connecting | Yellow (pulsing) | Connection attempt in progress |
|
| Connecting | Yellow (pulsing) | Connection attempt in progress |
|
||||||
|
|
||||||
|
### Project availability and path visibility
|
||||||
|
|
||||||
|
Node and project surfaces now use per-node project mappings (`nodeMappings`) instead of a single `project.nodeId` assumption.
|
||||||
|
|
||||||
|
- **Node cards / counts** include only projects with an `available: true` mapping for that node.
|
||||||
|
- **Node Details modal** lists one row per project available on the selected node and shows:
|
||||||
|
- project name
|
||||||
|
- project ID
|
||||||
|
- configured path for that node
|
||||||
|
- **Project node filter** in the Projects view is built from available mappings and uses canonical node-name resolution (`Node.name` → mapping name → source node name → node ID).
|
||||||
|
- **Project cards** show node availability as compact `Node → /path` rows:
|
||||||
|
- up to 3 rows inline
|
||||||
|
- `+N more` summary when additional mappings exist
|
||||||
|
- single-node projects still show the configured path clearly
|
||||||
|
- Mappings marked `available: false` are excluded from node counts, node filter options, node detail project rows, and project-card availability summaries.
|
||||||
|
|
||||||
### Persistence
|
### Persistence
|
||||||
|
|
||||||
The selected node persists across browser sessions via localStorage. If the selected remote node is unregistered, the dashboard automatically falls back to local mode.
|
The selected node persists across browser sessions via localStorage. If the selected remote node is unregistered, the dashboard automatically falls back to local mode.
|
||||||
|
|||||||
@@ -5722,10 +5722,29 @@ export function fetchProjects(): Promise<ProjectInfo[]> {
|
|||||||
return api<ProjectInfo[]>("/projects");
|
return api<ProjectInfo[]>("/projects");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Project info with source node metadata (added by server for remote projects) */
|
/** Dashboard-facing mapping contract for project availability on nodes. */
|
||||||
|
export interface ProjectNodeAvailability {
|
||||||
|
nodeId: string;
|
||||||
|
nodeName?: string;
|
||||||
|
path: string;
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project info with source node metadata (added by server for remote projects). */
|
||||||
export interface ProjectInfoWithSource extends ProjectInfo {
|
export interface ProjectInfoWithSource extends ProjectInfo {
|
||||||
/** Name of the source node (added by server for remote projects) */
|
/** Name of the source node (added by server for remote projects). */
|
||||||
_sourceNodeName?: string;
|
_sourceNodeName?: string;
|
||||||
|
/** Normalized per-node project mappings for dashboard UI. */
|
||||||
|
nodeMappings?: ProjectNodeAvailability[];
|
||||||
|
/** Compatibility fields accepted from in-flight server rollouts. */
|
||||||
|
projectNodeMappings?: ProjectNodeAvailability[];
|
||||||
|
pathMappings?: ProjectNodeAvailability[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasNodeMappingsSupport(project: ProjectInfoWithSource): boolean {
|
||||||
|
return Array.isArray(project.nodeMappings)
|
||||||
|
|| Array.isArray(project.projectNodeMappings)
|
||||||
|
|| Array.isArray(project.pathMappings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch all registered projects from all nodes (local + remote) */
|
/** Fetch all registered projects from all nodes (local + remote) */
|
||||||
|
|||||||
@@ -189,17 +189,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-card-action-resume:hover:not(:disabled) {
|
.project-card-action-resume:hover:not(:disabled) {
|
||||||
background: rgba(63, 185, 80, 0.1);
|
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||||
border-color: var(--color-success);
|
border-color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card-action-pause {
|
.project-card-action-pause {
|
||||||
color: var(--warning, #e3b341);
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card-action-pause:hover:not(:disabled) {
|
.project-card-action-pause:hover:not(:disabled) {
|
||||||
background: rgba(227, 179, 65, 0.1);
|
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
|
||||||
border-color: var(--warning, #e3b341);
|
border-color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card-action-open {
|
.project-card-action-open {
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-card-action-open:hover:not(:disabled) {
|
.project-card-action-open:hover:not(:disabled) {
|
||||||
background: rgba(88, 166, 255, 0.1);
|
background: color-mix(in srgb, var(--todo) 10%, transparent);
|
||||||
border-color: var(--todo);
|
border-color: var(--todo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,10 +217,59 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-card-action-remove:hover:not(:disabled) {
|
.project-card-action-remove:hover:not(:disabled) {
|
||||||
background: rgba(248, 81, 73, 0.1);
|
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||||
border-color: var(--color-error);
|
border-color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card-action-remove.is-armed {
|
.project-card-action-remove.is-armed {
|
||||||
background: color-mix(in srgb, var(--color-error) 14%, transparent);
|
background: color-mix(in srgb, var(--color-error) 14%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-card-availability {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-availability__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, auto) auto minmax(0, 1fr);
|
||||||
|
gap: var(--space-xs);
|
||||||
|
align-items: center;
|
||||||
|
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-availability__node {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-availability__arrow {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-availability__path {
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-availability__more {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.project-card-availability__row {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-availability__arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { memo, useCallback, useState } from "react";
|
|||||||
import { Play, Pause, AlertCircle, Loader2, Trash2, Folder, ArrowRight } from "lucide-react";
|
import { Play, Pause, AlertCircle, Loader2, Trash2, Folder, ArrowRight } from "lucide-react";
|
||||||
import "./ProjectCard.css";
|
import "./ProjectCard.css";
|
||||||
import type { RegisteredProject, ProjectHealth, ProjectStatus } from "@fusion/core";
|
import type { RegisteredProject, ProjectHealth, ProjectStatus } from "@fusion/core";
|
||||||
import type { NodeInfo } from "../api";
|
import type { ProjectNodeAvailability } from "../api";
|
||||||
|
|
||||||
export interface ProjectCardProps {
|
export interface ProjectCardProps {
|
||||||
project: RegisteredProject;
|
project: RegisteredProject;
|
||||||
@@ -11,17 +11,15 @@ export interface ProjectCardProps {
|
|||||||
onPause: (project: RegisteredProject) => void;
|
onPause: (project: RegisteredProject) => void;
|
||||||
onResume: (project: RegisteredProject) => void;
|
onResume: (project: RegisteredProject) => void;
|
||||||
onRemove: (project: RegisteredProject) => void;
|
onRemove: (project: RegisteredProject) => void;
|
||||||
node?: NodeInfo;
|
availabilityMappings?: Array<ProjectNodeAvailability & { displayName: string }>;
|
||||||
/** Fallback node name when the node object is not available (e.g., for remote projects) */
|
|
||||||
nodeNameFallback?: string;
|
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<ProjectStatus, { label: string; color: string; icon: typeof Play }> = {
|
const STATUS_CONFIG: Record<ProjectStatus, { label: string; color: string; icon: typeof Play }> = {
|
||||||
active: { label: "Active", color: "var(--success)", icon: Play },
|
active: { label: "Active", color: "var(--color-success)", icon: Play },
|
||||||
paused: { label: "Paused", color: "var(--warning)", icon: Pause },
|
paused: { label: "Paused", color: "var(--color-warning)", icon: Pause },
|
||||||
errored: { label: "Error", color: "var(--color-error)", icon: AlertCircle },
|
errored: { label: "Error", color: "var(--color-error)", icon: AlertCircle },
|
||||||
initializing: { label: "Initializing", color: "var(--info)", icon: Loader2 },
|
initializing: { label: "Initializing", color: "var(--color-warning)", icon: Loader2 },
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatRelativeTime(timestamp: string | undefined): string {
|
function formatRelativeTime(timestamp: string | undefined): string {
|
||||||
@@ -72,21 +70,18 @@ function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardP
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevNode = previous.node;
|
const prevMappings = previous.availabilityMappings ?? [];
|
||||||
const nextNode = next.node;
|
const nextMappings = next.availabilityMappings ?? [];
|
||||||
if (!prevNode && !nextNode) {
|
if (prevMappings.length !== nextMappings.length) return false;
|
||||||
// Compare fallback names
|
|
||||||
if (previous.nodeNameFallback !== next.nodeNameFallback) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!prevNode || !nextNode) return false;
|
|
||||||
|
|
||||||
return (
|
return prevMappings.every((mapping, index) => {
|
||||||
prevNode.id === nextNode.id
|
const nextMapping = nextMappings[index];
|
||||||
&& prevNode.name === nextNode.name
|
return Boolean(nextMapping)
|
||||||
&& prevNode.status === nextNode.status
|
&& mapping.nodeId === nextMapping.nodeId
|
||||||
&& prevNode.type === nextNode.type
|
&& mapping.path === nextMapping.path
|
||||||
);
|
&& mapping.displayName === nextMapping.displayName
|
||||||
|
&& mapping.available === nextMapping.available;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectCardInner({
|
function ProjectCardInner({
|
||||||
@@ -96,8 +91,7 @@ function ProjectCardInner({
|
|||||||
onPause,
|
onPause,
|
||||||
onResume,
|
onResume,
|
||||||
onRemove,
|
onRemove,
|
||||||
node,
|
availabilityMappings = [],
|
||||||
nodeNameFallback,
|
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: ProjectCardProps) {
|
}: ProjectCardProps) {
|
||||||
const [removeArmed, setRemoveArmed] = useState(false);
|
const [removeArmed, setRemoveArmed] = useState(false);
|
||||||
@@ -155,10 +149,19 @@ function ProjectCardInner({
|
|||||||
<h3 className="project-card-name" title={project.name}>
|
<h3 className="project-card-name" title={project.name}>
|
||||||
{project.name}
|
{project.name}
|
||||||
</h3>
|
</h3>
|
||||||
{(node || nodeNameFallback) && (
|
{availabilityMappings.length > 0 && (
|
||||||
<span className="node-badge" title={`Assigned node: ${node?.name ?? nodeNameFallback}`}>
|
<div className="project-card-availability" aria-label="Project node availability">
|
||||||
on: {node?.name ?? nodeNameFallback}
|
{availabilityMappings.slice(0, 3).map((mapping) => (
|
||||||
</span>
|
<div key={`${mapping.nodeId}-${mapping.path}`} className="project-card-availability__row" title={`${mapping.displayName} → ${mapping.path}`}>
|
||||||
|
<span className="project-card-availability__node">{mapping.displayName}</span>
|
||||||
|
<span className="project-card-availability__arrow">→</span>
|
||||||
|
<code className="project-card-availability__path">{truncatePath(mapping.path, 28)}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{availabilityMappings.length > 3 && (
|
||||||
|
<span className="project-card-availability__more">+{availabilityMappings.length - 3} more</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="project-card-path" title={project.path}>
|
<span className="project-card-path" title={project.path}>
|
||||||
{truncatePath(project.path)}
|
{truncatePath(project.path)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox, Server } from "lucide-react";
|
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox, Server } from "lucide-react";
|
||||||
import "./ProjectOverview.css";
|
import "./ProjectOverview.css";
|
||||||
import type { ProjectInfo, ProjectHealth, NodeInfo, ProjectInfoWithSource } from "../api";
|
import type { ProjectInfo, ProjectHealth, NodeInfo, ProjectInfoWithSource, ProjectNodeAvailability } from "../api";
|
||||||
import type { ProjectStatus } from "@fusion/core";
|
import type { ProjectStatus } from "@fusion/core";
|
||||||
import { ProjectCard } from "./ProjectCard";
|
import { ProjectCard } from "./ProjectCard";
|
||||||
|
import { getNodeMappingsForProject, resolveNodeDisplayName } from "../utils/nodeProjectAssignment";
|
||||||
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
|
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
|
||||||
import { useProjectHealth } from "../hooks/useProjectHealth";
|
import { useProjectHealth } from "../hooks/useProjectHealth";
|
||||||
|
|
||||||
@@ -27,6 +28,10 @@ interface ProjectWithHealth {
|
|||||||
health: ProjectHealth | null;
|
health: ProjectHealth | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DisplayMapping extends ProjectNodeAvailability {
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ProjectOverview - Multi-project grid view with stats and filtering
|
* ProjectOverview - Multi-project grid view with stats and filtering
|
||||||
*
|
*
|
||||||
@@ -92,7 +97,7 @@ export function ProjectOverview({
|
|||||||
|
|
||||||
// Filter by node if a node filter is active
|
// Filter by node if a node filter is active
|
||||||
if (activeNodeFilter !== null) {
|
if (activeNodeFilter !== null) {
|
||||||
filtered = filtered.filter(({ project }) => project.nodeId === activeNodeFilter);
|
filtered = filtered.filter(({ project }) => getNodeMappingsForProject(project).some((mapping) => mapping.available && mapping.nodeId === activeNodeFilter));
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
@@ -140,13 +145,15 @@ export function ProjectOverview({
|
|||||||
const erroredProjects = projects.filter((p) => p.status === "errored").length;
|
const erroredProjects = projects.filter((p) => p.status === "errored").length;
|
||||||
|
|
||||||
// Count unique nodes with projects (local + remote)
|
// Count unique nodes with projects (local + remote)
|
||||||
const nodesWithProjects = new Set<string | undefined>();
|
const nodesWithProjects = new Set<string>();
|
||||||
projects.forEach((p) => {
|
projects.forEach((project) => {
|
||||||
if (p.nodeId) {
|
getNodeMappingsForProject(project).forEach((mapping) => {
|
||||||
nodesWithProjects.add(p.nodeId);
|
if (mapping.available) {
|
||||||
}
|
nodesWithProjects.add(mapping.nodeId);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
const totalNodes = nodesWithProjects.size || (totalProjects > 0 ? 1 : 0);
|
const totalNodes = nodesWithProjects.size;
|
||||||
|
|
||||||
let totalActiveTasks = 0;
|
let totalActiveTasks = 0;
|
||||||
let totalCompletedTasks = 0;
|
let totalCompletedTasks = 0;
|
||||||
@@ -183,24 +190,25 @@ export function ProjectOverview({
|
|||||||
|
|
||||||
// Node filter options with project counts
|
// Node filter options with project counts
|
||||||
const nodeFilterOptions = useMemo(() => {
|
const nodeFilterOptions = useMemo(() => {
|
||||||
const nodeCounts = new Map<string | undefined, { name: string; count: number }>();
|
const nodeCounts = new Map<string, { name: string; count: number }>();
|
||||||
|
|
||||||
projects.forEach((p) => {
|
projects.forEach((project) => {
|
||||||
const nodeId = p.nodeId;
|
getNodeMappingsForProject(project)
|
||||||
const existing = nodeCounts.get(nodeId);
|
.filter((mapping) => mapping.available)
|
||||||
|
.forEach((mapping) => {
|
||||||
if (existing) {
|
const nodeId = mapping.nodeId;
|
||||||
existing.count++;
|
const existing = nodeCounts.get(nodeId);
|
||||||
} else {
|
const resolvedName = resolveNodeDisplayName(nodeId, mapping, nodes, project);
|
||||||
// Get the node name from the nodes list or use _sourceNodeName for remote projects
|
if (existing) {
|
||||||
const localNode = nodes.find((n) => n.id === nodeId);
|
existing.count += 1;
|
||||||
const nodeName = localNode?.name ?? p._sourceNodeName ?? "Local";
|
} else {
|
||||||
nodeCounts.set(nodeId, { name: nodeName, count: 1 });
|
nodeCounts.set(nodeId, { name: resolvedName, count: 1 });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(nodeCounts.entries()).map(([nodeId, { name, count }]) => ({
|
return Array.from(nodeCounts.entries()).map(([nodeId, { name, count }]) => ({
|
||||||
nodeId: nodeId ?? null,
|
nodeId,
|
||||||
name,
|
name,
|
||||||
count,
|
count,
|
||||||
}));
|
}));
|
||||||
@@ -404,16 +412,19 @@ export function ProjectOverview({
|
|||||||
{/* Project grid */}
|
{/* Project grid */}
|
||||||
<div className="project-grid">
|
<div className="project-grid">
|
||||||
{sortedProjects.map(({ project, health }) => {
|
{sortedProjects.map(({ project, health }) => {
|
||||||
const projectNode = nodes.find((node) => node.id === project.nodeId);
|
const availabilityMappings: DisplayMapping[] = getNodeMappingsForProject(project)
|
||||||
// Fallback: use server-provided _sourceNodeName for remote projects
|
.filter((mapping) => mapping.available)
|
||||||
const nodeNameFallback = !projectNode ? project._sourceNodeName : undefined;
|
.map((mapping) => ({
|
||||||
|
...mapping,
|
||||||
|
displayName: resolveNodeDisplayName(mapping.nodeId, mapping, nodes, project),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
project={project}
|
project={project}
|
||||||
health={health}
|
health={health}
|
||||||
node={projectNode}
|
availabilityMappings={availabilityMappings}
|
||||||
nodeNameFallback={nodeNameFallback}
|
|
||||||
onSelect={handleSelectProject}
|
onSelect={handleSelectProject}
|
||||||
onPause={onPauseProject}
|
onPause={onPauseProject}
|
||||||
onResume={onResumeProject}
|
onResume={onResumeProject}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import { NodeCard } from "../NodeCard";
|
import { NodeCard } from "../NodeCard";
|
||||||
import type { NodeInfo, ProjectInfo } from "../../api";
|
import type { NodeInfo, ProjectInfoWithSource } from "../../api";
|
||||||
import type { ComputedNodeSyncStatus } from "../../hooks/useNodeSettingsSync";
|
import type { ComputedNodeSyncStatus } from "../../hooks/useNodeSettingsSync";
|
||||||
|
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
@@ -47,8 +47,8 @@ function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
|
||||||
return {
|
const project: ProjectInfoWithSource = {
|
||||||
id: "proj-1",
|
id: "proj-1",
|
||||||
name: "Project One",
|
name: "Project One",
|
||||||
path: "/workspace/project-one",
|
path: "/workspace/project-one",
|
||||||
@@ -58,6 +58,12 @@ function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
|||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!project.nodeMappings && project.nodeId) {
|
||||||
|
project.nodeMappings = [{ nodeId: project.nodeId, path: project.path, available: true }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): ComputedNodeSyncStatus {
|
function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): ComputedNodeSyncStatus {
|
||||||
@@ -193,12 +199,12 @@ describe("NodeCard", () => {
|
|||||||
expect(onRemove).toHaveBeenCalledWith(node.id);
|
expect(onRemove).toHaveBeenCalledWith(node.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("local node counts include unassigned projects", () => {
|
it("local node counts only available mappings", () => {
|
||||||
const localNode = makeNode({ id: "local-1", type: "local" });
|
const localNode = makeNode({ id: "local-1", type: "local" });
|
||||||
const projects = [
|
const projects = [
|
||||||
makeProject({ id: "proj-1", nodeId: "local-1" }), // explicitly assigned
|
makeProject({ id: "proj-1", nodeId: "local-1" }),
|
||||||
makeProject({ id: "proj-2", nodeId: undefined }), // unassigned - runs on local
|
makeProject({ id: "proj-2", nodeMappings: [{ nodeId: "local-1", path: "/workspace/project-two", available: false }] }),
|
||||||
makeProject({ id: "proj-3", nodeId: "remote-1" }), // assigned to remote - not counted
|
makeProject({ id: "proj-3", nodeId: "remote-1" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -211,8 +217,7 @@ describe("NodeCard", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Local node should show 2 projects (explicitly assigned + unassigned)
|
expect(screen.getByText("1")).toBeDefined();
|
||||||
expect(screen.getByText("2")).toBeDefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("remote node counts exclude unassigned projects", () => {
|
it("remote node counts exclude unassigned projects", () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { NodesView } from "../NodesView";
|
import { NodesView } from "../NodesView";
|
||||||
import type { NodeInfo, ProjectInfo } from "../../api";
|
import type { NodeInfo, ProjectInfoWithSource } from "../../api";
|
||||||
import { useNodes } from "../../hooks/useNodes";
|
import { useNodes } from "../../hooks/useNodes";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
import { useNodeSettingsSync } from "../../hooks/useNodeSettingsSync";
|
import { useNodeSettingsSync } from "../../hooks/useNodeSettingsSync";
|
||||||
@@ -62,8 +62,8 @@ function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
|
||||||
return {
|
const project: ProjectInfoWithSource = {
|
||||||
id: "proj-1",
|
id: "proj-1",
|
||||||
name: "Project One",
|
name: "Project One",
|
||||||
path: "/workspace/project-one",
|
path: "/workspace/project-one",
|
||||||
@@ -73,6 +73,12 @@ function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
|||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!project.nodeMappings && project.nodeId) {
|
||||||
|
project.nodeMappings = [{ nodeId: project.nodeId, path: project.path, available: true }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeUseNodesResult(overrides: Partial<ReturnType<typeof useNodes>> = {}): ReturnType<typeof useNodes> {
|
function makeUseNodesResult(overrides: Partial<ReturnType<typeof useNodes>> = {}): ReturnType<typeof useNodes> {
|
||||||
@@ -274,11 +280,11 @@ describe("NodesView", () => {
|
|||||||
expect(screen.getByRole("dialog", { name: "Node details for Detail Node" })).toBeDefined();
|
expect(screen.getByRole("dialog", { name: "Node details for Detail Node" })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("local node project count includes unassigned projects in detail modal", () => {
|
it("local node detail modal only counts projects with available mappings", () => {
|
||||||
mockUseProjects.mockReturnValue({
|
mockUseProjects.mockReturnValue({
|
||||||
projects: [
|
projects: [
|
||||||
makeProject({ id: "proj-1", nodeId: "node-1" }), // explicitly assigned
|
makeProject({ id: "proj-1", nodeId: "node-1" }),
|
||||||
makeProject({ id: "proj-2", nodeId: undefined }), // unassigned - runs on local
|
makeProject({ id: "proj-2", nodeMappings: [{ nodeId: "node-1", path: "/workspace/project-two", available: false }] }),
|
||||||
],
|
],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -299,8 +305,7 @@ describe("NodesView", () => {
|
|||||||
expect(nodeCard).toBeInTheDocument();
|
expect(nodeCard).toBeInTheDocument();
|
||||||
fireEvent.click(nodeCard!);
|
fireEvent.click(nodeCard!);
|
||||||
|
|
||||||
// Modal should show "Projects (2)" - including the unassigned project
|
expect(screen.getByText("Projects (1)")).toBeDefined();
|
||||||
expect(screen.getByText("Projects (2)")).toBeDefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders close button and calls onClose when clicked", () => {
|
it("renders close button and calls onClose when clicked", () => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { describe, it, expect, vi } from "vitest";
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { ProjectCard } from "../ProjectCard";
|
import { ProjectCard } from "../ProjectCard";
|
||||||
import type { RegisteredProject, ProjectHealth } from "@fusion/core";
|
import type { RegisteredProject, ProjectHealth } from "@fusion/core";
|
||||||
import type { NodeInfo } from "../../api";
|
|
||||||
|
|
||||||
// Mock lucide-react to avoid SVG rendering issues in test env
|
// Mock lucide-react to avoid SVG rendering issues in test env
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
@@ -42,19 +41,6 @@ function makeHealth(overrides: Partial<ProjectHealth> = {}): ProjectHealth {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
|
||||||
return {
|
|
||||||
id: "node_001",
|
|
||||||
name: "Build Node",
|
|
||||||
type: "local",
|
|
||||||
status: "online",
|
|
||||||
maxConcurrent: 2,
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
describe("ProjectCard", () => {
|
describe("ProjectCard", () => {
|
||||||
@@ -74,12 +60,12 @@ describe("ProjectCard", () => {
|
|||||||
expect(screen.getByText("/path/to/project")).toBeDefined();
|
expect(screen.getByText("/path/to/project")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders assigned node badge when node is provided", () => {
|
it("renders node availability rows with path details", () => {
|
||||||
render(
|
render(
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
project={makeProject()}
|
project={makeProject()}
|
||||||
health={makeHealth()}
|
health={makeHealth()}
|
||||||
node={makeNode({ name: "Remote Worker" })}
|
availabilityMappings={[{ nodeId: "node-1", displayName: "Remote Worker", path: "/srv/work", available: true }]}
|
||||||
onSelect={noop}
|
onSelect={noop}
|
||||||
onPause={noop}
|
onPause={noop}
|
||||||
onResume={noop}
|
onResume={noop}
|
||||||
@@ -87,7 +73,29 @@ describe("ProjectCard", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("on: Remote Worker")).toBeDefined();
|
expect(screen.getByText("Remote Worker")).toBeDefined();
|
||||||
|
expect(screen.getByText("/srv/work")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows overflow indicator when more than three mappings exist", () => {
|
||||||
|
render(
|
||||||
|
<ProjectCard
|
||||||
|
project={makeProject()}
|
||||||
|
health={makeHealth()}
|
||||||
|
availabilityMappings={[
|
||||||
|
{ nodeId: "node-1", displayName: "Node One", path: "/one", available: true },
|
||||||
|
{ nodeId: "node-2", displayName: "Node Two", path: "/two", available: true },
|
||||||
|
{ nodeId: "node-3", displayName: "Node Three", path: "/three", available: true },
|
||||||
|
{ nodeId: "node-4", displayName: "Node Four", path: "/four", available: true },
|
||||||
|
]}
|
||||||
|
onSelect={noop}
|
||||||
|
onPause={noop}
|
||||||
|
onResume={noop}
|
||||||
|
onRemove={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("+1 more")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("truncates long paths", () => {
|
it("truncates long paths", () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useProjectHealth } from "../../hooks/useProjectHealth";
|
|||||||
// Extended type with source node info for cross-node tests
|
// Extended type with source node info for cross-node tests
|
||||||
interface ProjectInfoWithSource extends ProjectInfo {
|
interface ProjectInfoWithSource extends ProjectInfo {
|
||||||
_sourceNodeName?: string;
|
_sourceNodeName?: string;
|
||||||
|
nodeMappings?: Array<{ nodeId: string; path: string; available: boolean; nodeName?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default mock implementation
|
// Default mock implementation
|
||||||
@@ -59,22 +60,21 @@ vi.mock("../ProjectCard", () => ({
|
|||||||
ProjectCard: ({
|
ProjectCard: ({
|
||||||
project,
|
project,
|
||||||
onSelect,
|
onSelect,
|
||||||
node,
|
availabilityMappings,
|
||||||
nodeNameFallback,
|
}: {
|
||||||
}: {
|
project: ProjectInfo;
|
||||||
project: ProjectInfo;
|
|
||||||
onSelect: (p: ProjectInfo) => void;
|
onSelect: (p: ProjectInfo) => void;
|
||||||
node?: { id: string; name: string };
|
availabilityMappings?: Array<{ displayName: string; path: string }>;
|
||||||
nodeNameFallback?: string;
|
|
||||||
}) => (
|
}) => (
|
||||||
<div
|
<div
|
||||||
data-testid={`project-card-${project.id}`}
|
data-testid={`project-card-${project.id}`}
|
||||||
data-node={node?.name ?? nodeNameFallback ?? "none"}
|
data-node={(availabilityMappings ?? []).map((mapping) => mapping.displayName).join(",") || "none"}
|
||||||
onClick={() => onSelect(project)}
|
onClick={() => onSelect(project)}
|
||||||
>
|
>
|
||||||
{project.name}
|
{project.name}
|
||||||
{node && <span className="node-badge">{node.name}</span>}
|
{(availabilityMappings ?? []).map((mapping) => (
|
||||||
{nodeNameFallback && !node && <span className="node-badge">{nodeNameFallback}</span>}
|
<span key={`${mapping.displayName}-${mapping.path}`} className="node-badge">{mapping.displayName}</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@@ -85,7 +85,7 @@ vi.mock("../ProjectGridSkeleton", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
|
function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
|
||||||
return {
|
const project: ProjectInfoWithSource = {
|
||||||
id: "proj_abc123",
|
id: "proj_abc123",
|
||||||
name: "Test Project",
|
name: "Test Project",
|
||||||
path: "/home/user/projects/test",
|
path: "/home/user/projects/test",
|
||||||
@@ -96,6 +96,12 @@ function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInf
|
|||||||
lastActivityAt: new Date().toISOString(),
|
lastActivityAt: new Date().toISOString(),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!project.nodeMappings && project.nodeId) {
|
||||||
|
project.nodeMappings = [{ nodeId: project.nodeId, path: project.path, available: true }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|||||||
@@ -1,446 +1,122 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import {
|
import { renderHook, act } from "@testing-library/react";
|
||||||
fetchProjects,
|
import { useProjects } from "../useProjects";
|
||||||
registerProject,
|
import * as api from "../../api";
|
||||||
unregisterProject,
|
import type { ProjectInfoWithSource } from "../../api";
|
||||||
fetchProject,
|
|
||||||
updateProject,
|
|
||||||
detectProjects,
|
|
||||||
fetchProjectHealth,
|
|
||||||
fetchActivityFeed,
|
|
||||||
pauseProject,
|
|
||||||
resumeProject,
|
|
||||||
fetchFirstRunStatus,
|
|
||||||
fetchGlobalConcurrency,
|
|
||||||
fetchProjectTasks,
|
|
||||||
fetchProjectConfig,
|
|
||||||
type ProjectInfo,
|
|
||||||
type ProjectHealth,
|
|
||||||
type ActivityFeedEntry,
|
|
||||||
type FirstRunStatus,
|
|
||||||
type GlobalConcurrencyState,
|
|
||||||
type DetectedProject,
|
|
||||||
} from "../../api";
|
|
||||||
|
|
||||||
function mockFetchResponse(
|
vi.mock("../../api", () => ({
|
||||||
ok: boolean,
|
fetchProjectsAcrossNodes: vi.fn(),
|
||||||
body: unknown,
|
registerProject: vi.fn(),
|
||||||
status = ok ? 200 : 500,
|
updateProject: vi.fn(),
|
||||||
contentType = "application/json"
|
unregisterProject: vi.fn(),
|
||||||
) {
|
hasNodeMappingsSupport: vi.fn(),
|
||||||
const bodyText = JSON.stringify(body);
|
}));
|
||||||
return Promise.resolve({
|
|
||||||
ok,
|
const mockFetchProjectsAcrossNodes = vi.mocked(api.fetchProjectsAcrossNodes);
|
||||||
status,
|
const mockRegisterProject = vi.mocked(api.registerProject);
|
||||||
statusText: ok ? "OK" : "Error",
|
const mockUpdateProject = vi.mocked(api.updateProject);
|
||||||
headers: {
|
const mockUnregisterProject = vi.mocked(api.unregisterProject);
|
||||||
get: (name: string) =>
|
const mockHasNodeMappingsSupport = vi.mocked(api.hasNodeMappingsSupport);
|
||||||
name.toLowerCase() === "content-type" ? contentType : null,
|
|
||||||
},
|
function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
|
||||||
json: () => Promise.resolve(body),
|
return {
|
||||||
text: () => Promise.resolve(bodyText),
|
id: "proj-1",
|
||||||
} as unknown as Response);
|
name: "Project One",
|
||||||
|
path: "/workspace/project-one",
|
||||||
|
status: "active",
|
||||||
|
isolationMode: "in-process",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Project Management API", () => {
|
async function flushPromises(): Promise<void> {
|
||||||
const originalFetch = globalThis.fetch;
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useProjects", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
mockFetchProjectsAcrossNodes.mockReset();
|
||||||
|
mockRegisterProject.mockReset();
|
||||||
|
mockUpdateProject.mockReset();
|
||||||
|
mockUnregisterProject.mockReset();
|
||||||
|
mockHasNodeMappingsSupport.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fetchProjects", () => {
|
it("normalizes mapping-enabled payloads into project.nodeMappings", async () => {
|
||||||
it("returns empty array when no projects", async () => {
|
mockHasNodeMappingsSupport.mockReturnValue(true);
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([
|
||||||
|
makeProject({
|
||||||
|
id: "proj-1",
|
||||||
|
nodeMappings: [{ nodeId: "node-a", path: "/mnt/a", available: true }],
|
||||||
|
}),
|
||||||
|
makeProject({
|
||||||
|
id: "proj-2",
|
||||||
|
pathMappings: [{ nodeId: "node-b", path: "/mnt/b", available: false }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const result = await fetchProjects();
|
const { result } = renderHook(() => useProjects());
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
await act(async () => {
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
await flushPromises();
|
||||||
"/api/projects",
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns projects list when available", async () => {
|
expect(result.current.loading).toBe(false);
|
||||||
const mockProjects: ProjectInfo[] = [
|
expect(result.current.projects[0].nodeMappings).toEqual([
|
||||||
{
|
{ nodeId: "node-a", path: "/mnt/a", available: true, nodeName: undefined },
|
||||||
id: "proj_123",
|
]);
|
||||||
name: "Test Project",
|
expect(result.current.projects[1].nodeMappings).toEqual([
|
||||||
path: "/test/path",
|
{ nodeId: "node-b", path: "/mnt/b", available: false, nodeName: undefined },
|
||||||
status: "active",
|
]);
|
||||||
isolationMode: "in-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProjects));
|
|
||||||
|
|
||||||
const result = await fetchProjects();
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].id).toBe("proj_123");
|
|
||||||
expect(result[0].name).toBe("Test Project");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("registerProject", () => {
|
it("synthesizes a legacy fallback mapping from nodeId + path", async () => {
|
||||||
it("registers a new project with valid input", async () => {
|
mockHasNodeMappingsSupport.mockReturnValue(false);
|
||||||
const mockProject: ProjectInfo = {
|
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([
|
||||||
id: "proj_new",
|
makeProject({ id: "proj-legacy", nodeId: "node-legacy", _sourceNodeName: "Legacy Node" }),
|
||||||
name: "New Project",
|
]);
|
||||||
path: "/absolute/path",
|
|
||||||
status: "active",
|
|
||||||
isolationMode: "in-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
|
||||||
|
|
||||||
const result = await registerProject({
|
const { result } = renderHook(() => useProjects());
|
||||||
name: "New Project",
|
|
||||||
path: "/absolute/path",
|
|
||||||
isolationMode: "in-process",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.id).toBe("proj_new");
|
await act(async () => {
|
||||||
expect(result.name).toBe("New Project");
|
await flushPromises();
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "POST",
|
|
||||||
body: expect.any(String),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(result.current.projects[0].nodeMappings).toEqual([
|
||||||
|
{
|
||||||
|
nodeId: "node-legacy",
|
||||||
|
nodeName: "Legacy Node",
|
||||||
|
path: "/workspace/project-one",
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("unregisterProject", () => {
|
it("refreshes projects using the same normalization", async () => {
|
||||||
it("unregisters a project", async () => {
|
mockHasNodeMappingsSupport.mockReturnValue(false);
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
|
mockFetchProjectsAcrossNodes
|
||||||
|
.mockResolvedValueOnce([makeProject({ id: "proj-1", nodeId: "node-a" })])
|
||||||
|
.mockResolvedValueOnce([makeProject({ id: "proj-2", nodeId: "node-b" })]);
|
||||||
|
|
||||||
await unregisterProject("proj_test123");
|
const { result } = renderHook(() => useProjects());
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
await act(async () => {
|
||||||
"/api/projects/proj_test123",
|
await flushPromises();
|
||||||
expect.objectContaining({
|
|
||||||
method: "DELETE",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchProjectHealth", () => {
|
|
||||||
it("returns health metrics for a project", async () => {
|
|
||||||
const mockHealth: ProjectHealth = {
|
|
||||||
projectId: "proj_test123",
|
|
||||||
status: "active",
|
|
||||||
activeTaskCount: 5,
|
|
||||||
inFlightAgentCount: 2,
|
|
||||||
totalTasksCompleted: 10,
|
|
||||||
totalTasksFailed: 1,
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockHealth));
|
|
||||||
|
|
||||||
const result = await fetchProjectHealth("proj_test123");
|
|
||||||
|
|
||||||
expect(result.projectId).toBe("proj_test123");
|
|
||||||
expect(result.activeTaskCount).toBe(5);
|
|
||||||
expect(result.totalTasksCompleted).toBe(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchActivityFeed", () => {
|
|
||||||
it("returns activity feed entries", async () => {
|
|
||||||
const mockEntries: ActivityFeedEntry[] = [
|
|
||||||
{
|
|
||||||
id: "entry_1",
|
|
||||||
timestamp: "2026-01-01T00:00:00.000Z",
|
|
||||||
type: "task:created",
|
|
||||||
projectId: "proj_123",
|
|
||||||
projectName: "Test Project",
|
|
||||||
taskId: "FN-001",
|
|
||||||
taskTitle: "Test Task",
|
|
||||||
details: "Task created",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockEntries));
|
|
||||||
|
|
||||||
const result = await fetchActivityFeed();
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].type).toBe("task:created");
|
|
||||||
expect(result[0].projectName).toBe("Test Project");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports limit parameter", async () => {
|
await act(async () => {
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
await result.current.refresh();
|
||||||
|
|
||||||
await fetchActivityFeed({ limit: 10 });
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("limit=10"),
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports projectId filter", async () => {
|
expect(result.current.projects[0].id).toBe("proj-2");
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
expect(result.current.projects[0].nodeMappings?.[0]?.nodeId).toBe("node-b");
|
||||||
|
|
||||||
await fetchActivityFeed({ projectId: "proj_123" });
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("projectId=proj_123"),
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchFirstRunStatus", () => {
|
|
||||||
it("returns first run status", async () => {
|
|
||||||
const mockStatus: FirstRunStatus = {
|
|
||||||
hasProjects: false,
|
|
||||||
singleProjectPath: null,
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockStatus));
|
|
||||||
|
|
||||||
const result = await fetchFirstRunStatus();
|
|
||||||
|
|
||||||
expect(result.hasProjects).toBe(false);
|
|
||||||
expect(result.singleProjectPath).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns single project path when only one project", async () => {
|
|
||||||
const mockStatus: FirstRunStatus = {
|
|
||||||
hasProjects: true,
|
|
||||||
singleProjectPath: "/projects/my-project",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockStatus));
|
|
||||||
|
|
||||||
const result = await fetchFirstRunStatus();
|
|
||||||
|
|
||||||
expect(result.hasProjects).toBe(true);
|
|
||||||
expect(result.singleProjectPath).toBe("/projects/my-project");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchGlobalConcurrency", () => {
|
|
||||||
it("returns global concurrency state", async () => {
|
|
||||||
const mockState: GlobalConcurrencyState = {
|
|
||||||
globalMaxConcurrent: 4,
|
|
||||||
currentlyActive: 2,
|
|
||||||
queuedCount: 0,
|
|
||||||
projectsActive: { "proj_123": 2 },
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockState));
|
|
||||||
|
|
||||||
const result = await fetchGlobalConcurrency();
|
|
||||||
|
|
||||||
expect(result.globalMaxConcurrent).toBe(4);
|
|
||||||
expect(result.currentlyActive).toBe(2);
|
|
||||||
expect(result.projectsActive["proj_123"]).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pauseProject", () => {
|
|
||||||
it("pauses a project", async () => {
|
|
||||||
const mockProject: ProjectInfo = {
|
|
||||||
id: "proj_123",
|
|
||||||
name: "Test Project",
|
|
||||||
path: "/test/path",
|
|
||||||
status: "paused",
|
|
||||||
isolationMode: "in-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
|
||||||
|
|
||||||
const result = await pauseProject("proj_123");
|
|
||||||
|
|
||||||
expect(result.status).toBe("paused");
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects/proj_123/pause",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "POST",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resumeProject", () => {
|
|
||||||
it("resumes a paused project", async () => {
|
|
||||||
const mockProject: ProjectInfo = {
|
|
||||||
id: "proj_123",
|
|
||||||
name: "Test Project",
|
|
||||||
path: "/test/path",
|
|
||||||
status: "active",
|
|
||||||
isolationMode: "in-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
|
||||||
|
|
||||||
const result = await resumeProject("proj_123");
|
|
||||||
|
|
||||||
expect(result.status).toBe("active");
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects/proj_123/resume",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "POST",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchProjectTasks", () => {
|
|
||||||
it("fetches tasks for a specific project", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
|
||||||
|
|
||||||
await fetchProjectTasks("proj_123");
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("projectId=proj_123"),
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports pagination", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
|
||||||
|
|
||||||
await fetchProjectTasks("proj_123", 10, 20);
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("limit=10"),
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("offset=20"),
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchProjectConfig", () => {
|
|
||||||
it("fetches project config", async () => {
|
|
||||||
const mockConfig = { maxConcurrent: 4, rootDir: "/projects/test" };
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockConfig));
|
|
||||||
|
|
||||||
const result = await fetchProjectConfig("proj_123");
|
|
||||||
|
|
||||||
expect(result.maxConcurrent).toBe(4);
|
|
||||||
expect(result.rootDir).toBe("/projects/test");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchProject (single)", () => {
|
|
||||||
it("fetches a specific project by ID", async () => {
|
|
||||||
const mockProject: ProjectInfo = {
|
|
||||||
id: "proj_123",
|
|
||||||
name: "Specific Project",
|
|
||||||
path: "/specific/path",
|
|
||||||
status: "active",
|
|
||||||
isolationMode: "child-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
|
||||||
|
|
||||||
const result = await fetchProject("proj_123");
|
|
||||||
|
|
||||||
expect(result.id).toBe("proj_123");
|
|
||||||
expect(result.name).toBe("Specific Project");
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects/proj_123",
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("updateProject", () => {
|
|
||||||
it("updates project with valid data", async () => {
|
|
||||||
const mockProject: ProjectInfo = {
|
|
||||||
id: "proj_123",
|
|
||||||
name: "Updated Name",
|
|
||||||
path: "/test/path",
|
|
||||||
status: "active",
|
|
||||||
isolationMode: "in-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
|
||||||
|
|
||||||
const result = await updateProject("proj_123", { name: "Updated Name" });
|
|
||||||
|
|
||||||
expect(result.name).toBe("Updated Name");
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects/proj_123",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "PATCH",
|
|
||||||
body: expect.any(String),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates project isolationMode", async () => {
|
|
||||||
const mockProject: ProjectInfo = {
|
|
||||||
id: "proj_123",
|
|
||||||
name: "Test Project",
|
|
||||||
path: "/test/path",
|
|
||||||
status: "active",
|
|
||||||
isolationMode: "child-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
|
||||||
|
|
||||||
const result = await updateProject("proj_123", { isolationMode: "child-process" });
|
|
||||||
|
|
||||||
expect(result.isolationMode).toBe("child-process");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("detectProjects", () => {
|
|
||||||
it("auto-detects projects in a base path", async () => {
|
|
||||||
const mockDetected = {
|
|
||||||
projects: [
|
|
||||||
{ path: "/home/user/project1", suggestedName: "project1", existing: false },
|
|
||||||
{ path: "/home/user/project2", suggestedName: "project2", existing: true },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockDetected));
|
|
||||||
|
|
||||||
const result = await detectProjects("/home/user");
|
|
||||||
|
|
||||||
expect(result.projects).toHaveLength(2);
|
|
||||||
expect(result.projects[0].path).toBe("/home/user/project1");
|
|
||||||
expect(result.projects[0].suggestedName).toBe("project1");
|
|
||||||
expect(result.projects[1].existing).toBe(true);
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects/detect",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ basePath: "/home/user" }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses home directory when basePath not provided", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { projects: [] }));
|
|
||||||
|
|
||||||
await detectProjects();
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects/detect",
|
|
||||||
expect.objectContaining({
|
|
||||||
body: JSON.stringify({ basePath: undefined }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
|||||||
import type { ProjectInfo } from "../api";
|
import type { ProjectInfo } from "../api";
|
||||||
import {
|
import {
|
||||||
fetchProjectsAcrossNodes,
|
fetchProjectsAcrossNodes,
|
||||||
|
hasNodeMappingsSupport,
|
||||||
registerProject,
|
registerProject,
|
||||||
unregisterProject,
|
unregisterProject,
|
||||||
updateProject,
|
updateProject,
|
||||||
type ProjectCreateInput,
|
type ProjectCreateInput,
|
||||||
type ProjectInfoWithSource,
|
type ProjectInfoWithSource,
|
||||||
|
type ProjectNodeAvailability,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
|
||||||
export interface UseProjectsResult {
|
export interface UseProjectsResult {
|
||||||
@@ -29,6 +31,43 @@ export interface UseProjectsResult {
|
|||||||
const POLL_INTERVAL_MS = 5000; // 5 seconds
|
const POLL_INTERVAL_MS = 5000; // 5 seconds
|
||||||
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
||||||
|
|
||||||
|
function normalizeNodeMappings(project: ProjectInfoWithSource): ProjectNodeAvailability[] {
|
||||||
|
const mappingSource = hasNodeMappingsSupport(project)
|
||||||
|
? (project.nodeMappings ?? project.projectNodeMappings ?? project.pathMappings ?? [])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const normalizedMappings = mappingSource
|
||||||
|
.filter((mapping) => Boolean(mapping?.nodeId) && Boolean(mapping?.path))
|
||||||
|
.map((mapping) => ({
|
||||||
|
nodeId: mapping.nodeId,
|
||||||
|
nodeName: mapping.nodeName,
|
||||||
|
path: mapping.path,
|
||||||
|
available: mapping.available !== false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (normalizedMappings.length > 0) {
|
||||||
|
return normalizedMappings;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.nodeId && project.path) {
|
||||||
|
return [{
|
||||||
|
nodeId: project.nodeId,
|
||||||
|
nodeName: project._sourceNodeName,
|
||||||
|
path: project.path,
|
||||||
|
available: true,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProjects(projects: ProjectInfoWithSource[]): ProjectInfoWithSource[] {
|
||||||
|
return projects.map((project) => ({
|
||||||
|
...project,
|
||||||
|
nodeMappings: normalizeNodeMappings(project),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for fetching and managing projects.
|
* Hook for fetching and managing projects.
|
||||||
* Automatically polls for updates every 5 seconds.
|
* Automatically polls for updates every 5 seconds.
|
||||||
@@ -46,7 +85,7 @@ export function useProjects(): UseProjectsResult {
|
|||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const data = await fetchProjectsAcrossNodes();
|
const data = await fetchProjectsAcrossNodes();
|
||||||
setProjects(data);
|
setProjects(normalizeProjects(data));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to fetch projects");
|
setError(err instanceof Error ? err.message : "Failed to fetch projects");
|
||||||
// Don't clear existing projects on error - keep showing stale data
|
// Don't clear existing projects on error - keep showing stale data
|
||||||
@@ -62,10 +101,11 @@ export function useProjects(): UseProjectsResult {
|
|||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
try {
|
try {
|
||||||
const data = await fetchProjectsAcrossNodes();
|
const data = await fetchProjectsAcrossNodes();
|
||||||
|
const normalizedData = normalizeProjects(data);
|
||||||
const elapsed = Math.round(performance.now() - t0);
|
const elapsed = Math.round(performance.now() - t0);
|
||||||
console.log(`[useProjects] initial fetchProjectsAcrossNodes took ${elapsed}ms (${data.length} projects)`);
|
console.log(`[useProjects] initial fetchProjectsAcrossNodes took ${elapsed}ms (${normalizedData.length} projects)`);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setProjects(data);
|
setProjects(normalizedData);
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
isProjectRoutedToNode,
|
getAvailableNodeMappingsForNode,
|
||||||
getProjectsForNode,
|
getNodeMappingsForProject,
|
||||||
getProjectCountForNode,
|
getProjectCountForNode,
|
||||||
|
getProjectsForNode,
|
||||||
getUnassignedProjectCount,
|
getUnassignedProjectCount,
|
||||||
|
isProjectAvailableOnNode,
|
||||||
|
resolveNodeDisplayName,
|
||||||
} from "../nodeProjectAssignment";
|
} from "../nodeProjectAssignment";
|
||||||
import type { NodeInfo, ProjectInfo } from "../../api";
|
import type { NodeInfo, ProjectInfoWithSource } from "../../api";
|
||||||
|
|
||||||
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||||
return {
|
return {
|
||||||
id: "node-1",
|
id: "node-1",
|
||||||
name: "Test Node",
|
name: "Node One",
|
||||||
type: "local",
|
type: "local",
|
||||||
status: "online",
|
status: "online",
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
@@ -20,7 +23,7 @@ function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
|
||||||
return {
|
return {
|
||||||
id: "proj-1",
|
id: "proj-1",
|
||||||
name: "Project One",
|
name: "Project One",
|
||||||
@@ -29,155 +32,68 @@ function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
|||||||
isolationMode: "in-process",
|
isolationMode: "in-process",
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
nodeMappings: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("nodeProjectAssignment", () => {
|
describe("nodeProjectAssignment", () => {
|
||||||
describe("isProjectRoutedToNode", () => {
|
it("normalizes mapping entries and defaults available=true", () => {
|
||||||
describe("local node", () => {
|
const project = makeProject({
|
||||||
const localNode = makeNode({ id: "local-1", type: "local" });
|
nodeMappings: [
|
||||||
|
{ nodeId: "node-1", path: "/mnt/one", available: true },
|
||||||
it("returns true for projects explicitly assigned to this local node", () => {
|
{ nodeId: "node-2", path: "/mnt/two", available: false },
|
||||||
const project = makeProject({ id: "proj-1", nodeId: "local-1" });
|
{ nodeId: "", path: "/bad", available: true },
|
||||||
expect(isProjectRoutedToNode(project, localNode)).toBe(true);
|
],
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for unassigned projects (nodeId undefined)", () => {
|
|
||||||
const project = makeProject({ id: "proj-1", nodeId: undefined });
|
|
||||||
expect(isProjectRoutedToNode(project, localNode)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for unassigned projects (nodeId null)", () => {
|
|
||||||
const project = makeProject({ id: "proj-1", nodeId: null as unknown as string });
|
|
||||||
expect(isProjectRoutedToNode(project, localNode)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for projects assigned to other nodes", () => {
|
|
||||||
const project = makeProject({ id: "proj-1", nodeId: "other-node" });
|
|
||||||
expect(isProjectRoutedToNode(project, localNode)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for projects assigned to remote nodes", () => {
|
|
||||||
const project = makeProject({ id: "proj-1", nodeId: "remote-1" });
|
|
||||||
expect(isProjectRoutedToNode(project, localNode)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("remote node", () => {
|
expect(getNodeMappingsForProject(project)).toEqual([
|
||||||
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
|
{ nodeId: "node-1", path: "/mnt/one", available: true, nodeName: undefined },
|
||||||
|
{ nodeId: "node-2", path: "/mnt/two", available: false, nodeName: undefined },
|
||||||
it("returns true for projects explicitly assigned to this remote node", () => {
|
]);
|
||||||
const project = makeProject({ id: "proj-1", nodeId: "remote-1" });
|
|
||||||
expect(isProjectRoutedToNode(project, remoteNode)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for unassigned projects (nodeId undefined)", () => {
|
|
||||||
const project = makeProject({ id: "proj-1", nodeId: undefined });
|
|
||||||
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for unassigned projects (nodeId null)", () => {
|
|
||||||
const project = makeProject({ id: "proj-1", nodeId: null as unknown as string });
|
|
||||||
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for projects assigned to local nodes", () => {
|
|
||||||
const project = makeProject({ id: "proj-1", nodeId: "local-1" });
|
|
||||||
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for projects assigned to other remote nodes", () => {
|
|
||||||
const project = makeProject({ id: "proj-1", nodeId: "other-remote" });
|
|
||||||
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getProjectsForNode", () => {
|
it("filters available mappings for a given node", () => {
|
||||||
it("returns all projects routed to a local node (including unassigned)", () => {
|
const project = makeProject({
|
||||||
const localNode = makeNode({ id: "local-1", type: "local" });
|
nodeMappings: [
|
||||||
const projects: ProjectInfo[] = [
|
{ nodeId: "node-1", path: "/mnt/live", available: true },
|
||||||
makeProject({ id: "proj-1", nodeId: "local-1" }), // assigned to this local node
|
{ nodeId: "node-1", path: "/mnt/down", available: false },
|
||||||
makeProject({ id: "proj-2", nodeId: undefined }), // unassigned
|
],
|
||||||
makeProject({ id: "proj-3", nodeId: "other-local" }), // assigned to different local node
|
|
||||||
makeProject({ id: "proj-4", nodeId: "remote-1" }), // assigned to remote
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = getProjectsForNode(projects, localNode);
|
|
||||||
expect(result.map((p) => p.id)).toEqual(["proj-1", "proj-2"]);
|
|
||||||
});
|
});
|
||||||
|
const node = makeNode({ id: "node-1" });
|
||||||
|
|
||||||
it("returns only explicitly assigned projects for a remote node", () => {
|
expect(getAvailableNodeMappingsForNode(project, node)).toEqual([
|
||||||
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
|
{ nodeId: "node-1", path: "/mnt/live", available: true, nodeName: undefined },
|
||||||
const projects: ProjectInfo[] = [
|
]);
|
||||||
makeProject({ id: "proj-1", nodeId: "remote-1" }), // assigned to this remote node
|
expect(isProjectAvailableOnNode(project, node)).toBe(true);
|
||||||
makeProject({ id: "proj-2", nodeId: undefined }), // unassigned
|
|
||||||
makeProject({ id: "proj-3", nodeId: "local-1" }), // assigned to local
|
|
||||||
makeProject({ id: "proj-4", nodeId: "other-remote" }), // assigned to other remote
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = getProjectsForNode(projects, remoteNode);
|
|
||||||
expect(result.map((p) => p.id)).toEqual(["proj-1"]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getProjectCountForNode", () => {
|
it("builds project lists and counts from available mappings only", () => {
|
||||||
it("returns correct count for local node (includes unassigned)", () => {
|
const node = makeNode({ id: "node-1" });
|
||||||
const localNode = makeNode({ id: "local-1", type: "local" });
|
const projects = [
|
||||||
const projects: ProjectInfo[] = [
|
makeProject({ id: "proj-a", nodeMappings: [{ nodeId: "node-1", path: "/a", available: true }] }),
|
||||||
makeProject({ id: "proj-1", nodeId: "local-1" }),
|
makeProject({ id: "proj-b", nodeMappings: [{ nodeId: "node-1", path: "/b", available: false }] }),
|
||||||
makeProject({ id: "proj-2", nodeId: undefined }),
|
makeProject({ id: "proj-c", nodeMappings: [{ nodeId: "node-2", path: "/c", available: true }] }),
|
||||||
makeProject({ id: "proj-3", nodeId: undefined }),
|
];
|
||||||
];
|
|
||||||
|
|
||||||
expect(getProjectCountForNode(projects, localNode)).toBe(3);
|
expect(getProjectsForNode(projects, node).map((project) => project.id)).toEqual(["proj-a"]);
|
||||||
});
|
expect(getProjectCountForNode(projects, node)).toBe(1);
|
||||||
|
|
||||||
it("returns correct count for remote node (explicit only)", () => {
|
|
||||||
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
|
|
||||||
const projects: ProjectInfo[] = [
|
|
||||||
makeProject({ id: "proj-1", nodeId: "remote-1" }),
|
|
||||||
makeProject({ id: "proj-2", nodeId: "remote-1" }),
|
|
||||||
makeProject({ id: "proj-3", nodeId: undefined }),
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(getProjectCountForNode(projects, remoteNode)).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 0 when no projects are routed to the node", () => {
|
|
||||||
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
|
|
||||||
const projects: ProjectInfo[] = [
|
|
||||||
makeProject({ id: "proj-1", nodeId: "local-1" }),
|
|
||||||
makeProject({ id: "proj-2", nodeId: undefined }),
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(getProjectCountForNode(projects, remoteNode)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getUnassignedProjectCount", () => {
|
it("resolves display names in canonical order", () => {
|
||||||
it("counts projects without nodeId", () => {
|
const nodes = [makeNode({ id: "node-1", name: "Primary Node" })];
|
||||||
const projects: ProjectInfo[] = [
|
const project = makeProject({ _sourceNodeName: "Source Node" });
|
||||||
makeProject({ id: "proj-1", nodeId: undefined }),
|
|
||||||
makeProject({ id: "proj-2", nodeId: null as unknown as string }),
|
|
||||||
makeProject({ id: "proj-3", nodeId: "local-1" }),
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(getUnassignedProjectCount(projects)).toBe(2);
|
expect(resolveNodeDisplayName("node-1", { nodeId: "node-1", path: "/a", available: true, nodeName: "Mapping Name" }, nodes, project)).toBe("Primary Node");
|
||||||
});
|
expect(resolveNodeDisplayName("node-2", { nodeId: "node-2", path: "/b", available: true, nodeName: "Mapping Name" }, nodes, project)).toBe("Mapping Name");
|
||||||
|
expect(resolveNodeDisplayName("node-3", undefined, nodes, project)).toBe("Source Node");
|
||||||
|
expect(resolveNodeDisplayName("node-4", undefined, nodes, makeProject({ _sourceNodeName: undefined }))).toBe("node-4");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 0 when all projects are assigned", () => {
|
it("counts unassigned projects as projects without mappings", () => {
|
||||||
const projects: ProjectInfo[] = [
|
expect(getUnassignedProjectCount([
|
||||||
makeProject({ id: "proj-1", nodeId: "local-1" }),
|
makeProject({ id: "proj-a", nodeMappings: [] }),
|
||||||
makeProject({ id: "proj-2", nodeId: "remote-1" }),
|
makeProject({ id: "proj-b", nodeMappings: [{ nodeId: "node-1", path: "/b", available: true }] }),
|
||||||
];
|
])).toBe(1);
|
||||||
|
|
||||||
expect(getUnassignedProjectCount(projects)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 0 for empty array", () => {
|
|
||||||
expect(getUnassignedProjectCount([])).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,75 +1,55 @@
|
|||||||
/**
|
import type { NodeInfo, ProjectInfoWithSource, ProjectNodeAvailability } from "../api";
|
||||||
* Node-Project Assignment Utilities
|
|
||||||
*
|
|
||||||
* Provides canonical counting logic for projects routed to a node.
|
|
||||||
*
|
|
||||||
* **Runtime Behavior:**
|
|
||||||
* - Projects with `nodeId` pointing to a remote node → run on that remote node
|
|
||||||
* - Projects with `nodeId` pointing to a local node → run on that local node
|
|
||||||
* - Projects without `nodeId` (unassigned) → run on local in-process runtime
|
|
||||||
*
|
|
||||||
* **Counting Rules:**
|
|
||||||
* - Local nodes: include both explicitly-assigned projects AND unassigned projects
|
|
||||||
* - Remote nodes: include only explicitly-assigned projects
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { NodeInfo, ProjectInfo } from "../api";
|
function isNonEmptyString(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
/**
|
|
||||||
* Check if a project is routed to a specific node based on runtime rules.
|
|
||||||
*
|
|
||||||
* @param project - The project to check
|
|
||||||
* @param node - The node to check against
|
|
||||||
* @returns true if the project runs on this node
|
|
||||||
*/
|
|
||||||
export function isProjectRoutedToNode(project: ProjectInfo, node: NodeInfo): boolean {
|
|
||||||
if (node.type === "remote") {
|
|
||||||
// Remote nodes: only explicit assignment counts
|
|
||||||
return project.nodeId === node.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local nodes: explicit assignment OR unassigned (null/undefined)
|
|
||||||
if (project.nodeId === node.id) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unassigned projects run on local in-process runtime
|
|
||||||
if (project.nodeId === undefined || project.nodeId === null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function getNodeMappingsForProject(project: ProjectInfoWithSource): ProjectNodeAvailability[] {
|
||||||
* Get all projects that are routed to a specific node.
|
const mappings = project.nodeMappings ?? [];
|
||||||
*
|
return mappings
|
||||||
* @param projects - All projects
|
.filter((mapping) => isNonEmptyString(mapping.nodeId) && isNonEmptyString(mapping.path))
|
||||||
* @param node - The node to filter by
|
.map((mapping) => ({
|
||||||
* @returns Projects routed to this node
|
nodeId: mapping.nodeId,
|
||||||
*/
|
nodeName: mapping.nodeName,
|
||||||
export function getProjectsForNode(projects: ProjectInfo[], node: NodeInfo): ProjectInfo[] {
|
path: mapping.path,
|
||||||
return projects.filter((project) => isProjectRoutedToNode(project, node));
|
available: mapping.available !== false,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function getAvailableNodeMappingsForNode(
|
||||||
* Get the count of projects routed to a specific node.
|
project: ProjectInfoWithSource,
|
||||||
*
|
node: NodeInfo,
|
||||||
* @param projects - All projects
|
): ProjectNodeAvailability[] {
|
||||||
* @param node - The node to count projects for
|
return getNodeMappingsForProject(project).filter(
|
||||||
* @returns Number of projects on this node
|
(mapping) => mapping.nodeId === node.id && mapping.available,
|
||||||
*/
|
);
|
||||||
export function getProjectCountForNode(projects: ProjectInfo[], node: NodeInfo): number {
|
}
|
||||||
|
|
||||||
|
export function isProjectAvailableOnNode(project: ProjectInfoWithSource, node: NodeInfo): boolean {
|
||||||
|
return getAvailableNodeMappingsForNode(project, node).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectsForNode(projects: ProjectInfoWithSource[], node: NodeInfo): ProjectInfoWithSource[] {
|
||||||
|
return projects.filter((project) => isProjectAvailableOnNode(project, node));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectCountForNode(projects: ProjectInfoWithSource[], node: NodeInfo): number {
|
||||||
return getProjectsForNode(projects, node).length;
|
return getProjectsForNode(projects, node).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function resolveNodeDisplayName(
|
||||||
* Get the count of unassigned projects (projects without nodeId).
|
nodeId: string,
|
||||||
* These projects run on the local in-process runtime.
|
mapping: ProjectNodeAvailability | undefined,
|
||||||
*
|
nodes: NodeInfo[],
|
||||||
* @param projects - All projects
|
project: ProjectInfoWithSource,
|
||||||
* @returns Number of unassigned projects
|
): string {
|
||||||
*/
|
const nodeMatch = nodes.find((node) => node.id === nodeId);
|
||||||
export function getUnassignedProjectCount(projects: ProjectInfo[]): number {
|
if (nodeMatch?.name) return nodeMatch.name;
|
||||||
return projects.filter((project) => project.nodeId === undefined || project.nodeId === null).length;
|
if (mapping?.nodeName) return mapping.nodeName;
|
||||||
|
if (project._sourceNodeName) return project._sourceNodeName;
|
||||||
|
return nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnassignedProjectCount(projects: ProjectInfoWithSource[]): number {
|
||||||
|
return projects.filter((project) => getNodeMappingsForProject(project).length === 0).length;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user