feat(FN-1850): aggregate projects across connected nodes

- Add /api/projects/across-nodes to merge local projects with online remote node project lists
- Expose fetchProjectsAcrossNodes and ProjectInfoWithSource, and switch useProjects to consume cross-node data
- Update ProjectOverview and ProjectCard with node badges, node count stats, and a node filter dropdown plus responsive styles
- Add server and dashboard test coverage for cross-node aggregation, filtering, and hook behavior changes
- Include a changeset for @gsxdsm/fusion minor release and preserve stale-channel SSE reconnect guards during merge
This commit is contained in:
Fusion
2026-04-18 03:08:00 -07:00
committed by gsxdsm
parent 76123c9538
commit 53babb3524
14 changed files with 1674 additions and 55 deletions

View File

@@ -3280,6 +3280,17 @@ export function fetchProjects(): Promise<ProjectInfo[]> {
return api<ProjectInfo[]>("/projects");
}
/** 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) */
_sourceNodeName?: string;
}
/** Fetch all registered projects from all nodes (local + remote) */
export function fetchProjectsAcrossNodes(): Promise<ProjectInfoWithSource[]> {
return api<ProjectInfoWithSource[]>("/projects/across-nodes");
}
/** Fetch all registered nodes */
export function fetchNodes(): Promise<NodeInfo[]> {
return api<NodeInfo[]>("/nodes");

View File

@@ -11,6 +11,8 @@ export interface ProjectCardProps {
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;
isLoading?: boolean;
}
@@ -71,7 +73,11 @@ function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardP
const prevNode = previous.node;
const nextNode = next.node;
if (!prevNode && !nextNode) return true;
if (!prevNode && !nextNode) {
// Compare fallback names
if (previous.nodeNameFallback !== next.nodeNameFallback) return false;
return true;
}
if (!prevNode || !nextNode) return false;
return (
@@ -90,6 +96,7 @@ function ProjectCardInner({
onResume,
onRemove,
node,
nodeNameFallback,
isLoading = false,
}: ProjectCardProps) {
const [removeArmed, setRemoveArmed] = useState(false);
@@ -147,9 +154,9 @@ function ProjectCardInner({
<h3 className="project-card-name" title={project.name}>
{project.name}
</h3>
{node && (
<span className="node-badge" title={`Assigned node: ${node.name}`}>
on: {node.name}
{(node || nodeNameFallback) && (
<span className="node-badge" title={`Assigned node: ${node?.name ?? nodeNameFallback}`}>
on: {node?.name ?? nodeNameFallback}
</span>
)}
<span className="project-card-path" title={project.path}>

View File

@@ -1,13 +1,13 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox } from "lucide-react";
import type { ProjectInfo, ProjectHealth, NodeInfo } from "../api";
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox, Server } from "lucide-react";
import type { ProjectInfo, ProjectHealth, NodeInfo, ProjectInfoWithSource } from "../api";
import type { ProjectStatus } from "@fusion/core";
import { ProjectCard } from "./ProjectCard";
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
import { useProjectHealth } from "../hooks/useProjectHealth";
export interface ProjectOverviewProps {
projects: ProjectInfo[];
projects: ProjectInfoWithSource[];
loading?: boolean;
onSelectProject: (project: ProjectInfo) => void;
onAddProject: () => void;
@@ -22,7 +22,7 @@ type FilterTab = "all" | "active" | "paused" | "errored";
type SortOption = "name" | "activity" | "status";
interface ProjectWithHealth {
project: ProjectInfo;
project: ProjectInfoWithSource;
health: ProjectHealth | null;
}
@@ -47,6 +47,7 @@ export function ProjectOverview({
nodes = [],
}: ProjectOverviewProps) {
const [activeFilter, setActiveFilter] = useState<FilterTab>("all");
const [activeNodeFilter, setActiveNodeFilter] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<SortOption>("activity");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
@@ -88,8 +89,13 @@ export function ProjectOverview({
filtered = filtered.filter(({ project }) => project.status === activeFilter);
}
// Filter by node if a node filter is active
if (activeNodeFilter !== null) {
filtered = filtered.filter(({ project }) => project.nodeId === activeNodeFilter);
}
return filtered;
}, [projectsWithHealth, activeFilter]);
}, [projectsWithHealth, activeFilter, activeNodeFilter]);
// Sort projects
const sortedProjects = useMemo(() => {
@@ -130,6 +136,15 @@ export function ProjectOverview({
const activeProjects = projects.filter((p) => p.status === "active").length;
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 totalNodes = nodesWithProjects.size || (totalProjects > 0 ? 1 : 0);
let totalActiveTasks = 0;
let totalCompletedTasks = 0;
let totalInFlightAgents = 0;
@@ -146,6 +161,7 @@ export function ProjectOverview({
totalProjects,
activeProjects,
erroredProjects,
totalNodes,
totalActiveTasks,
totalCompletedTasks,
totalInFlightAgents,
@@ -162,6 +178,31 @@ export function ProjectOverview({
};
}, [projects]);
// 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 });
}
});
return Array.from(nodeCounts.entries()).map(([nodeId, { name, count }]) => ({
nodeId: nodeId ?? null,
name,
count,
}));
}, [projects, nodes]);
// Handle sort change
const handleSort = useCallback((option: SortOption) => {
if (sortBy === option) {
@@ -267,6 +308,17 @@ export function ProjectOverview({
</div>
</div>
)}
{stats.totalNodes > 1 && (
<div className="project-stat project-stat--nodes">
<div className="project-stat__icon">
<Server size={16} />
</div>
<div className="project-stat__content">
<span className="project-stat__value">{stats.totalNodes}</span>
<span className="project-stat__label">Nodes</span>
</div>
</div>
)}
</div>
<button
className="btn btn-primary project-overview__add-btn"
@@ -310,6 +362,28 @@ export function ProjectOverview({
</button>
</div>
{/* Node filter dropdown */}
{nodeFilterOptions.length > 1 && (
<div className="project-node-filter">
<Server size={14} />
<select
value={activeNodeFilter ?? ""}
onChange={(e) => {
setActiveNodeFilter(e.target.value || null);
}}
className="project-node-filter-select"
aria-label="Filter by node"
>
<option value="">All Nodes</option>
{nodeFilterOptions.map(({ nodeId, name, count }) => (
<option key={nodeId ?? "local"} value={nodeId ?? ""}>
{name} ({count})
</option>
))}
</select>
</div>
)}
{/* Sort dropdown */}
<div className="project-sort">
<Filter size={14} />
@@ -338,12 +412,15 @@ export function ProjectOverview({
<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;
return (
<ProjectCard
key={project.id}
project={project}
health={health}
node={projectNode}
nodeNameFallback={nodeNameFallback}
onSelect={handleSelectProject}
onPause={onPauseProject}
onResume={onResumeProject}

View File

@@ -143,7 +143,111 @@ const mockMilestoneValidationRollup = {
blockedAssertions: 0,
pendingAssertions: 0,
unlinkedAssertions: 0,
state: "not_started",
state: "not_started" as const,
};
/** Extended mock telemetry for parity tests — mirrors FN-1569 schema */
const mockMilestoneValidationTelemetryWithRounds = {
validationContract: {
assertions: [
{ id: "CA-001", title: "Auth works", assertion: "Users can log in", status: "pending" as const, orderIndex: 0 },
{ id: "CA-002", title: "Session persists", assertion: "Token refresh works", status: "pending" as const, orderIndex: 1 },
],
featureFulfillment: {
"F-001": { assertionIds: ["CA-001"], featureTitle: "User model", featureStatus: "in-progress" },
},
},
validationTelemetry: {
validationRounds: [
{
roundId: "VR-001",
featureId: "F-001",
featureTitle: "User model",
validatorStatus: "failed" as const,
implementationAttempt: 1,
validatorAttempt: 2, // retry count (validatorAttempt = retry count)
failedAssertionIds: ["CA-001"],
generatedFixFeatureIds: [],
startedAt: "2026-04-10T09:00:00.000Z",
completedAt: "2026-04-10T09:05:00.000Z",
},
{
roundId: "VR-002",
featureId: "F-001",
featureTitle: "User model",
validatorStatus: "failed" as const,
implementationAttempt: 2,
validatorAttempt: 3, // higher retry count — iterating surface
failedAssertionIds: ["CA-002"],
generatedFixFeatureIds: [],
startedAt: "2026-04-10T09:10:00.000Z",
completedAt: "2026-04-10T09:15:00.000Z",
},
],
lastValidatorStatus: "failed" as const,
totalRuns: 2,
},
fixFeatures: [
{
id: "FF-001",
title: "Fix: token refresh",
sourceFeatureId: "F-001",
runId: "VR-001",
failedAssertionIds: ["CA-001"],
status: "defined" as const,
loopState: "idle" as const,
},
],
rollup: {
milestoneId: "MS-001",
totalAssertions: 2,
passedAssertions: 0,
failedAssertions: 2,
blockedAssertions: 0,
pendingAssertions: 0,
unlinkedAssertions: 0,
state: "failed" as const,
},
};
/** Blocked milestone telemetry — mirrors FN-1569 blocked state */
const mockBlockedMilestoneTelemetry = {
validationContract: {
assertions: [
{ id: "CA-003", title: "API reachable", assertion: "External API responds", status: "blocked" as const, orderIndex: 0 },
],
featureFulfillment: {},
},
validationTelemetry: {
validationRounds: [
{
roundId: "VR-BLK",
featureId: "F-BLK",
featureTitle: "API integration",
validatorStatus: "blocked" as const,
implementationAttempt: 1,
validatorAttempt: 1,
failedAssertionIds: ["CA-003"],
generatedFixFeatureIds: [],
blockedReason: "External API unavailable — connection refused after 3 retries",
startedAt: "2026-04-10T10:00:00.000Z",
completedAt: "2026-04-10T10:01:00.000Z",
},
],
lastValidatorStatus: "blocked" as const,
totalRuns: 1,
},
fixFeatures: [],
rollup: {
milestoneId: "MS-001",
totalAssertions: 1,
passedAssertions: 0,
failedAssertions: 0,
blockedAssertions: 1,
pendingAssertions: 0,
unlinkedAssertions: 0,
state: "blocked" as const,
},
};
const mockMilestoneValidationTelemetry = {
@@ -292,9 +396,10 @@ function parseMissionEventsResponse(url: string, events = mockMissionEvents) {
};
}
function getValidationApiMock(url: string): unknown | null {
function getValidationApiMock(url: string, telemetryOverride?: unknown): unknown | null {
const telemetry = telemetryOverride ?? mockMilestoneValidationTelemetry;
if (url.includes("/validation-telemetry")) {
return mockMilestoneValidationTelemetry;
return telemetry;
}
if (url.includes("/validation-runs")) {
@@ -429,6 +534,73 @@ function createDetailFetchMock(events = mockMissionEvents) {
});
}
function createFetchMockWithTelemetry(telemetryOverride: unknown) {
return vi.fn().mockImplementation((url: string) => {
if (url.includes("/missions/health")) {
return Promise.resolve(mockApiResponse(mockMissionHealthById));
}
if (url.includes("/events")) {
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url)));
}
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "M-001";
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
}
if (url.includes("/autopilot")) {
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
}
const validationResponse = getValidationApiMock(url, telemetryOverride);
if (validationResponse !== null) {
return Promise.resolve(mockApiResponse(validationResponse));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse(mockMissionDetail));
}
return Promise.resolve(mockApiResponse(mockMissions));
});
}
function createDetailFetchMockWithTelemetry(events: unknown[], telemetryOverride: unknown) {
return vi.fn().mockImplementation((url: string) => {
if (url.includes("/missions/health")) {
return Promise.resolve(mockApiResponse(mockMissionHealthById));
}
if (url.includes("/events")) {
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url, events as typeof mockMissionEvents)));
}
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "M-001";
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
}
if (url.includes("/autopilot")) {
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
}
const validationResponse = getValidationApiMock(url, telemetryOverride);
if (validationResponse !== null) {
return Promise.resolve(mockApiResponse(validationResponse));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
const missionId = extractMissionId(url);
if (missionId === "M-001") {
return Promise.resolve(mockApiResponse(mockMissionDetail));
}
}
return Promise.resolve(mockApiResponse(mockMissions));
});
}
function createFetchMockWithHealth(
missions: Array<Record<string, unknown>>,
healthByMissionId: Record<string, unknown>,
@@ -2415,4 +2587,192 @@ describe("MissionManager", () => {
});
});
});
// ── Step 2: Factory parity — contract/telemetry/fix-feature coverage ────────
//
// Validates FN-1569 schema parity from API telemetry payloads through UI rendering.
// Extends test fixtures with validationContract, validationTelemetry, and fixFeatures
// mirroring the exact schema fields used by MissionManager.tsx telemetry section.
describe("Factory parity — contract/telemetry/fix-feature coverage", () => {
it("renders validation telemetry section in detail view after API response", async () => {
globalThis.fetch = createDetailFetchMockWithTelemetry(mockMissionEvents, mockMilestoneValidationTelemetryWithRounds);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-back-btn")).toBeDefined();
});
// After async telemetry loads, validation telemetry section should appear
await waitFor(() => {
expect(screen.getByText("Validation Telemetry")).toBeDefined();
}, { timeout: 3000 });
// Total runs shown in header meta
await waitFor(() => {
expect(screen.getByText(/2 rounds/)).toBeDefined();
}, { timeout: 3000 });
// Last validator status shown in header meta
await waitFor(() => {
expect(screen.getByText(/Last failed/)).toBeDefined();
}, { timeout: 3000 });
});
it("shows blocked reason surface when validation round is blocked", async () => {
globalThis.fetch = createDetailFetchMockWithTelemetry(mockMissionEvents, mockBlockedMilestoneTelemetry);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
// Wait for telemetry to load
await waitFor(() => {
expect(screen.getByText("Validation Telemetry")).toBeDefined();
}, { timeout: 3000 });
// Last validator status shows blocked
await waitFor(() => {
expect(screen.getByText(/Last blocked/)).toBeDefined();
}, { timeout: 3000 });
// Blocked reason surface should appear (.mission-blocked-reason class)
await waitFor(() => {
expect(document.querySelector(".mission-blocked-reason")).not.toBeNull();
}, { timeout: 3000 });
// Blocked reason text should be visible (use getAllByText since it may appear in both milestone-blocked-reason and round-blocked-reason)
await waitFor(() => {
const matches = screen.getAllByText(/External API unavailable/);
expect(matches.length).toBeGreaterThan(0);
}, { timeout: 3000 });
});
it("does not show blocked-reason surface for failed (non-blocked) rounds", async () => {
// Regression: failed rounds should NOT show blocked-reason surface
globalThis.fetch = createDetailFetchMockWithTelemetry(mockMissionEvents, mockMilestoneValidationTelemetryWithRounds);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
// Wait for telemetry to load
await waitFor(() => {
expect(screen.getByText("Validation Telemetry")).toBeDefined();
}, { timeout: 3000 });
// Blocked reason text from the blocked telemetry should NOT appear
// (the mockMissionDetail has a milestone without blocked telemetry)
expect(screen.queryByText(/External API unavailable/)).toBeNull();
});
it("displays fix-features with source linkage in telemetry section", async () => {
globalThis.fetch = createDetailFetchMockWithTelemetry(mockMissionEvents, mockMilestoneValidationTelemetryWithRounds);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
// Wait for telemetry to load
await waitFor(() => {
expect(screen.getByText(/Validation Telemetry/)).toBeDefined();
}, { timeout: 3000 });
// Fix features should appear with their source linkage
await waitFor(() => {
expect(screen.getByText("Fix: token refresh")).toBeDefined();
}, { timeout: 3000 });
// Source feature ID should be visible (clickable link to source feature)
await waitFor(() => {
expect(screen.getByText("F-001")).toBeDefined();
}, { timeout: 3000 });
});
it("blocked mission exposes resume affordance with aria-label", async () => {
// Test that a mission with blocked status shows the Resume button
const blockedMission = {
...mockMissionDetail,
status: "blocked" as const,
};
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (url.includes("/missions/health")) {
return Promise.resolve(mockApiResponse(mockMissionHealthById));
}
if (url.includes("/events")) {
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url)));
}
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "M-001";
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
}
if (url.includes("/autopilot")) {
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
}
const validationResponse = getValidationApiMock(url);
if (validationResponse !== null) {
return Promise.resolve(mockApiResponse(validationResponse));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse(blockedMission));
}
return Promise.resolve(mockApiResponse(mockMissions));
});
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-back-btn")).toBeDefined();
});
// Resume button with aria-label="Resume mission" should appear for blocked mission
await waitFor(() => {
const resumeButton = screen.getByLabelText("Resume mission");
expect(resumeButton).toBeDefined();
}, { timeout: 3000 });
});
it("activity tab metadata toggle still works after telemetry changes", async () => {
// Regression: mission events metadata toggle (mission-event-metadata-*) must remain functional
// Uses same pattern as existing passing test (lines ~912-923)
globalThis.fetch = createDetailFetchMockWithTelemetry(mockMissionEvents, mockMilestoneValidationTelemetryWithRounds);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-tab-activity")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mission-tab-activity"));
await waitFor(() => {
expect(screen.getByTestId("mission-activity-events")).toBeDefined();
expect(screen.getByText("Mission started")).toBeDefined();
});
// Toggle metadata for event E-002 which has metadata { queueDepth: 4 }
fireEvent.click(screen.getByTestId("mission-event-metadata-E-002"));
expect(screen.getByText(/"queueDepth": 4/)).toBeDefined();
});
});
});

View File

@@ -4,6 +4,11 @@ import { ProjectOverview } from "../ProjectOverview";
import type { ProjectInfo, ProjectHealth } from "@fusion/core";
import { useProjectHealth } from "../../hooks/useProjectHealth";
// Extended type with source node info for cross-node tests
interface ProjectInfoWithSource extends ProjectInfo {
_sourceNodeName?: string;
}
// Default mock implementation
function createDefaultHealthMap(projectIds: string[]): Record<string, ProjectHealth> {
return projectIds.reduce((acc, id) => {
@@ -45,14 +50,31 @@ vi.mock("lucide-react", async () => {
AlertCircle: () => <span data-testid="alert-icon"></span>,
Folder: () => <span data-testid="folder-icon">📁</span>,
Inbox: () => <span data-testid="inbox-icon">📥</span>,
Server: () => <span data-testid="server-icon">🖥</span>,
};
});
// Mock ProjectCard
vi.mock("../ProjectCard", () => ({
ProjectCard: ({ project, onSelect }: { project: ProjectInfo; onSelect: (p: ProjectInfo) => void }) => (
<div data-testid={`project-card-${project.id}`} onClick={() => onSelect(project)}>
ProjectCard: ({
project,
onSelect,
node,
nodeNameFallback,
}: {
project: ProjectInfo;
onSelect: (p: ProjectInfo) => void;
node?: { id: string; name: string };
nodeNameFallback?: string;
}) => (
<div
data-testid={`project-card-${project.id}`}
data-node={node?.name ?? nodeNameFallback ?? "none"}
onClick={() => onSelect(project)}
>
{project.name}
{node && <span className="node-badge">{node.name}</span>}
{nodeNameFallback && !node && <span className="node-badge">{nodeNameFallback}</span>}
</div>
),
}));
@@ -62,7 +84,7 @@ vi.mock("../ProjectGridSkeleton", () => ({
ProjectGridSkeleton: () => <div data-testid="project-grid-skeleton">Loading...</div>,
}));
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
return {
id: "proj_abc123",
name: "Test Project",
@@ -354,6 +376,171 @@ describe("ProjectOverview", () => {
expect(erroredTab?.className).toContain("has-errors");
});
describe("FN-1850: Node filter and badges", () => {
it("shows node badge for project with node association", () => {
const localNode = { id: "node_local", name: "Local", type: "local" as const, status: "online" as const, maxConcurrent: 2, createdAt: "", updatedAt: "" };
render(
<ProjectOverview
projects={[makeProject({ id: "proj_1", nodeId: "node_local" })]}
nodes={[localNode]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
const card = screen.getByTestId("project-card-proj_1");
expect(card.getAttribute("data-node")).toBe("Local");
});
it("renders node name fallback for remote projects without local node object", () => {
// Remote project with _sourceNodeName but no matching local node
render(
<ProjectOverview
projects={[
makeProject({
id: "proj_remote",
nodeId: "node_remote_abc",
_sourceNodeName: "Remote Alpha",
}),
]}
nodes={[]} // No matching local node
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
const card = screen.getByTestId("project-card-proj_remote");
expect(card.getAttribute("data-node")).toBe("Remote Alpha");
});
it("shows node filter dropdown when projects have multiple nodes", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", nodeId: "node_1" }),
makeProject({ id: "proj_2", nodeId: "node_2" }),
]}
nodes={[
{ id: "node_1", name: "Node One", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
{ id: "node_2", name: "Node Two", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Node filter dropdown should be present
expect(screen.getByLabelText("Filter by node")).toBeDefined();
});
it("node filter dropdown filters projects by node", async () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", name: "Project One", nodeId: "node_alpha" }),
makeProject({ id: "proj_2", name: "Project Two", nodeId: "node_beta" }),
]}
nodes={[
{ id: "node_alpha", name: "Alpha", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
{ id: "node_beta", name: "Beta", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Initially shows both projects
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
expect(screen.getByTestId("project-card-proj_2")).toBeDefined();
// Select "Alpha" from the node filter
const nodeFilter = screen.getByLabelText("Filter by node");
fireEvent.change(nodeFilter, { target: { value: "node_alpha" } });
// Should only show Alpha project
await waitFor(() => {
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
});
expect(screen.queryByTestId("project-card-proj_2")).toBeNull();
});
it("shows nodes stat in header when projects span multiple nodes", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", nodeId: "node_1" }),
makeProject({ id: "proj_2", nodeId: "node_2" }),
]}
nodes={[
{ id: "node_1", name: "Node One", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
{ id: "node_2", name: "Node Two", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Nodes stat should be visible
const nodesStats = screen.getByText("Nodes").closest(".project-stat__content");
expect(nodesStats?.querySelector(".project-stat__value")?.textContent).toBe("2");
});
it("does not show node filter when all projects are local", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1" }), // No nodeId - local project
makeProject({ id: "proj_2" }),
]}
nodes={[]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Node filter should not be present
expect(screen.queryByLabelText("Filter by node")).toBeNull();
});
it("does not show nodes stat when only one node (or no node ID)", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1" }), // No nodeId
]}
nodes={[]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Nodes stat should not be present
expect(screen.queryByText("Nodes")).toBeNull();
});
});
describe("mobile responsive structure", () => {
it("renders overview with correct class structure for mobile CSS targets", () => {
const { container } = render(
@@ -436,6 +623,30 @@ describe("ProjectOverview", () => {
expect(container.querySelector(".project-sort-select")).not.toBeNull();
expect(screen.getByLabelText("Sort projects")).toBeDefined();
});
it("renders node filter select with correct classes for mobile", () => {
const { container } = render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", nodeId: "node_1" }),
makeProject({ id: "proj_2", nodeId: "node_2" }),
]}
nodes={[
{ id: "node_1", name: "Node One", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
{ id: "node_2", name: "Node Two", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
expect(container.querySelector(".project-node-filter")).not.toBeNull();
expect(container.querySelector(".project-node-filter-select")).not.toBeNull();
expect(screen.getByLabelText("Filter by node")).toBeDefined();
});
});
describe("FN-1734: Health polling scroll position regression", () => {

View File

@@ -2,16 +2,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useProjects } from "../useProjects";
import * as api from "../../api";
import type { ProjectInfo } from "../../api";
import type { ProjectInfo, ProjectInfoWithSource } from "../../api";
vi.mock("../../api", () => ({
fetchProjects: vi.fn(),
fetchProjectsAcrossNodes: vi.fn(),
registerProject: vi.fn(),
unregisterProject: vi.fn(),
updateProject: vi.fn(),
}));
const mockFetchProjects = vi.mocked(api.fetchProjects);
const mockFetchProjectsAcrossNodes = vi.mocked(api.fetchProjectsAcrossNodes);
const mockUpdateProject = vi.mocked(api.updateProject);
const mockRegisterProject = vi.mocked(api.registerProject);
const mockUnregisterProject = vi.mocked(api.unregisterProject);
@@ -24,7 +24,7 @@ async function flushPromises(): Promise<void> {
describe("useProjects", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchProjects.mockReset();
mockFetchProjectsAcrossNodes.mockReset();
mockUpdateProject.mockReset();
mockRegisterProject.mockReset();
mockUnregisterProject.mockReset();
@@ -45,7 +45,6 @@ describe("useProjects", () => {
if (originalVisibilityState) {
Object.defineProperty(document, "visibilityState", originalVisibilityState);
} else {
delete (document as any).visibilityState;
}
});
@@ -68,7 +67,7 @@ describe("useProjects", () => {
it("refetches projects when visibility changes from hidden to visible", async () => {
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const initialProject: ProjectInfo = {
const initialProject: ProjectInfoWithSource = {
id: "proj_001",
name: "Initial Project",
path: "/initial/path",
@@ -77,7 +76,7 @@ describe("useProjects", () => {
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const refreshedProject: ProjectInfo = {
const refreshedProject: ProjectInfoWithSource = {
id: "proj_001",
name: "Updated Project",
path: "/initial/path",
@@ -87,7 +86,7 @@ describe("useProjects", () => {
updatedAt: "2026-01-02T00:00:00.000Z",
};
mockFetchProjects.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
const { result } = renderHook(() => useProjects());
@@ -110,11 +109,11 @@ describe("useProjects", () => {
});
expect(result.current.projects[0].name).toBe("Updated Project");
expect(mockFetchProjects).toHaveBeenCalledTimes(2);
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(2);
});
it("does not refetch when visibility changes to hidden", async () => {
const initialProject: ProjectInfo = {
const initialProject: ProjectInfoWithSource = {
id: "proj_001",
name: "Test Project",
path: "/test/path",
@@ -123,7 +122,7 @@ describe("useProjects", () => {
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
mockFetchProjects.mockResolvedValueOnce([initialProject]);
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([initialProject]);
renderHook(() => useProjects());
@@ -131,18 +130,18 @@ describe("useProjects", () => {
await flushPromises();
});
mockFetchProjects.mockClear();
mockFetchProjectsAcrossNodes.mockClear();
setVisibilityState("hidden");
await dispatchVisibilityChange();
expect(mockFetchProjects).not.toHaveBeenCalled();
expect(mockFetchProjectsAcrossNodes).not.toHaveBeenCalled();
});
it("debounces rapid visibility changes (minimum 1 second between fetches)", async () => {
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const initialProject: ProjectInfo = {
const initialProject: ProjectInfoWithSource = {
id: "proj_001",
name: "Test Project",
path: "/test/path",
@@ -151,7 +150,7 @@ describe("useProjects", () => {
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
mockFetchProjects.mockResolvedValue([initialProject]);
mockFetchProjectsAcrossNodes.mockResolvedValue([initialProject]);
renderHook(() => useProjects());
@@ -159,7 +158,7 @@ describe("useProjects", () => {
await flushPromises();
});
mockFetchProjects.mockClear();
mockFetchProjectsAcrossNodes.mockClear();
vi.setSystemTime(new Date("2026-01-01T00:00:01.100Z"));
setVisibilityState("hidden");
@@ -168,7 +167,7 @@ describe("useProjects", () => {
setVisibilityState("visible");
await dispatchVisibilityChange();
expect(mockFetchProjects).toHaveBeenCalledTimes(1);
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(1);
for (let i = 0; i < 5; i++) {
setVisibilityState("hidden");
@@ -178,7 +177,7 @@ describe("useProjects", () => {
await dispatchVisibilityChange();
}
expect(mockFetchProjects).toHaveBeenCalledTimes(1);
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(1);
vi.setSystemTime(new Date("2026-01-01T00:00:02.200Z"));
setVisibilityState("hidden");
@@ -187,18 +186,18 @@ describe("useProjects", () => {
setVisibilityState("visible");
await dispatchVisibilityChange();
expect(mockFetchProjects).toHaveBeenCalledTimes(2);
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(2);
});
it("cleans up visibility change listener on unmount", async () => {
mockFetchProjects.mockResolvedValueOnce([]);
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([]);
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
const { unmount } = renderHook(() => useProjects());
await waitFor(() => {
expect(mockFetchProjects).toHaveBeenCalledTimes(1);
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(1);
});
unmount();
@@ -210,8 +209,8 @@ describe("useProjects", () => {
});
describe("basic functionality", () => {
it("fetches projects on mount", async () => {
const mockProjects: ProjectInfo[] = [
it("fetches projects on mount using cross-node endpoint", async () => {
const mockProjects: ProjectInfoWithSource[] = [
{
id: "proj_001",
name: "Test Project",
@@ -222,7 +221,7 @@ describe("useProjects", () => {
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchProjects.mockResolvedValueOnce(mockProjects);
mockFetchProjectsAcrossNodes.mockResolvedValueOnce(mockProjects);
const { result } = renderHook(() => useProjects());
@@ -236,7 +235,7 @@ describe("useProjects", () => {
});
it("handles errors gracefully", async () => {
mockFetchProjects.mockRejectedValueOnce(new Error("Failed to fetch"));
mockFetchProjectsAcrossNodes.mockRejectedValueOnce(new Error("Failed to fetch"));
const { result } = renderHook(() => useProjects());
@@ -258,7 +257,7 @@ describe("useProjects", () => {
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
mockFetchProjects.mockResolvedValueOnce([]);
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([]);
mockRegisterProject.mockResolvedValueOnce(newProject);
const { result } = renderHook(() => useProjects());
@@ -278,7 +277,7 @@ describe("useProjects", () => {
});
it("unregister removes project optimistically", async () => {
const mockProjects: ProjectInfo[] = [
const mockProjects: ProjectInfoWithSource[] = [
{
id: "proj_001",
name: "Test Project",
@@ -289,7 +288,7 @@ describe("useProjects", () => {
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchProjects.mockResolvedValueOnce(mockProjects);
mockFetchProjectsAcrossNodes.mockResolvedValueOnce(mockProjects);
mockUnregisterProject.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useProjects());
@@ -308,7 +307,7 @@ describe("useProjects", () => {
});
it("update modifies project optimistically", async () => {
const mockProjects: ProjectInfo[] = [
const mockProjects: ProjectInfoWithSource[] = [
{
id: "proj_001",
name: "Test Project",
@@ -323,7 +322,7 @@ describe("useProjects", () => {
...mockProjects[0],
name: "Updated Name",
};
mockFetchProjects.mockResolvedValueOnce(mockProjects);
mockFetchProjectsAcrossNodes.mockResolvedValueOnce(mockProjects);
mockUpdateProject.mockResolvedValueOnce(updatedProject);
const { result } = renderHook(() => useProjects());
@@ -342,7 +341,7 @@ describe("useProjects", () => {
});
it("refresh manually refetches projects", async () => {
const initialProject: ProjectInfo = {
const initialProject: ProjectInfoWithSource = {
id: "proj_001",
name: "Initial",
path: "/test/path",
@@ -351,11 +350,11 @@ describe("useProjects", () => {
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const refreshedProject: ProjectInfo = {
const refreshedProject: ProjectInfoWithSource = {
...initialProject,
name: "Refreshed",
};
mockFetchProjects.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
const { result } = renderHook(() => useProjects());
@@ -371,5 +370,47 @@ describe("useProjects", () => {
expect(result.current.projects[0].name).toBe("Refreshed");
});
it("returns projects with _sourceNodeName from aggregated endpoint", async () => {
const mockProjects: ProjectInfoWithSource[] = [
{
id: "proj_local",
name: "Local Project",
path: "/local/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "proj_remote",
name: "Remote Project",
path: "/remote/path",
status: "active",
isolationMode: "child-process",
nodeId: "node_alpha",
_sourceNodeName: "Alpha Node",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchProjectsAcrossNodes.mockResolvedValueOnce(mockProjects);
const { result } = renderHook(() => useProjects());
await act(async () => {
await flushPromises();
});
expect(result.current.projects).toHaveLength(2);
const localProject = result.current.projects.find((p) => p.id === "proj_local");
expect(localProject?._sourceNodeName).toBeUndefined();
expect(localProject?.nodeId).toBeUndefined();
const remoteProject = result.current.projects.find((p) => p.id === "proj_remote");
expect(remoteProject?._sourceNodeName).toBe("Alpha Node");
expect(remoteProject?.nodeId).toBe("node_alpha");
});
});
});

View File

@@ -1,16 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { ProjectInfo } from "../api";
import {
fetchProjects,
fetchProjectsAcrossNodes,
registerProject,
unregisterProject,
updateProject,
type ProjectCreateInput,
type ProjectInfoWithSource,
} from "../api";
export interface UseProjectsResult {
/** List of all registered projects */
projects: ProjectInfo[];
/** List of all registered projects (local + remote) */
projects: ProjectInfoWithSource[];
/** Loading state for initial fetch */
loading: boolean;
/** Error message if fetch failed */
@@ -35,7 +36,7 @@ const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
* Provides optimistic updates for UI responsiveness.
*/
export function useProjects(): UseProjectsResult {
const [projects, setProjects] = useState<ProjectInfo[]>([]);
const [projects, setProjects] = useState<ProjectInfoWithSource[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -44,7 +45,7 @@ export function useProjects(): UseProjectsResult {
const refresh = useCallback(async () => {
try {
setError(null);
const data = await fetchProjects();
const data = await fetchProjectsAcrossNodes();
setProjects(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch projects");
@@ -59,7 +60,7 @@ export function useProjects(): UseProjectsResult {
async function load() {
setLoading(true);
try {
const data = await fetchProjects();
const data = await fetchProjectsAcrossNodes();
if (!cancelled) {
setProjects(data);
setError(null);

View File

@@ -170,6 +170,9 @@ export function useTasks(options?: UseTasksOptions) {
// (e.g., sseEnabled flipped to false during a pending reconnect timer in sse-bus).
let active = true;
// Guard against stale callbacks: when sseEnabled flips false or the
// effect unmounts, these handlers must not fire refreshTasks into a
// missions-only view where the SSE should be inactive.
const handleCreated = (e: MessageEvent) => {
if (isStale()) return;
const task = normalizeTask(JSON.parse(e.data) as Task);
@@ -267,6 +270,8 @@ export function useTasks(options?: UseTasksOptions) {
"task:deleted": handleDeleted,
"task:merged": handleMerged,
},
// Guard onReconnect against stale SSE callbacks: do not call refreshTasks
// if the SSE was disabled or the effect unmounted while reconnect was pending.
onReconnect: () => {
if (!active) return;
if (isStale()) return;

View File

@@ -55,6 +55,12 @@ function forceReconnect(channel: Channel): void {
if (channel.closed) return;
if (channel.subscribers.size === 0 || channel.reconnectTimer) return;
// Guard against calling onReconnect callbacks for a channel that has been
// closed while the heartbeat timer fired. This prevents stale SSE events from
// firing into unsubscribed/mounted-out consumers during rapid view switches.
const ch = channels.get(channel.url);
if (!ch || ch !== channel) return;
// A teardown means events may have been missed while the stream was
// down. Signal resync to each subscriber so they can refetch
// authoritative state.
@@ -63,7 +69,12 @@ function forceReconnect(channel: Channel): void {
channel.reconnectTimer = setTimeout(() => {
channel.reconnectTimer = null;
if (channel.closed) return;
if (channel.subscribers.size > 0) openChannel(channel);
// Re-check after timer fires — the channel may have been closed or
// the subscription count changed during the delay.
const current = channels.get(channel.url);
if (current && current === channel && channel.subscribers.size > 0) {
openChannel(channel);
}
}, RECONNECT_DELAY_MS);
}

View File

@@ -2399,6 +2399,42 @@ body {
box-shadow: var(--focus-ring);
}
/* --- Node Filter Dropdown --- */
.project-node-filter {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--text-muted);
font-size: 13px;
}
.project-node-filter-select {
appearance: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-muted);
font-size: 13px;
padding: 6px 28px 6px 10px;
cursor: pointer;
font-family: var(--font-primary);
transition:
border-color var(--transition-fast),
color var(--transition-fast);
background-image: none;
}
.project-node-filter-select:hover {
border-color: var(--text-dim);
color: var(--text);
}
.project-node-filter-select:focus {
outline: none;
border-color: var(--todo);
box-shadow: var(--focus-ring);
}
/* --- Project Grid --- */
.project-grid {
display: grid;
@@ -2709,6 +2745,16 @@ body {
margin-left: var(--space-sm);
}
.project-node-filter {
width: 100%;
justify-content: space-between;
}
.project-node-filter-select {
flex: 1;
margin-left: var(--space-sm);
}
/* Grid */
.project-grid {
grid-template-columns: 1fr;