From acc2e9ceb8f18d45fa81d82b0663a8ec42ad24be Mon Sep 17 00:00:00 2001 From: ddonaldson130 Date: Thu, 9 Jul 2026 03:16:55 -0400 Subject: [PATCH] fix(dashboard): project-scope Command Center analytics (FUX-037) Apply FUX-037 projectId scoping to Command Center and Reliability view. --- packages/dashboard/app/api/legacy.ts | 2 +- .../app/components/ReliabilityView.tsx | 18 +- .../__tests__/ReliabilityView.test.tsx | 185 ++++++++++-------- .../command-center/CommandCenter.tsx | 37 ++-- .../command-center/MissionControlPanel.tsx | 12 +- .../components/command-center/SdlcFunnel.tsx | 3 +- .../CommandCenter.mobile-scroll.test.tsx | 2 + .../CommandCenter.tablet-layout.test.tsx | 2 + .../__tests__/CommandCenter.test.tsx | 37 ++-- .../__tests__/MissionControlPanel.test.tsx | 16 ++ .../__tests__/SdlcFunnel.test.tsx | 16 ++ .../command-center/areas/ActivityArea.tsx | 6 +- .../command-center/areas/EcosystemArea.tsx | 4 +- .../command-center/areas/GithubArea.tsx | 3 +- .../command-center/areas/GitlabArea.tsx | 3 +- .../command-center/areas/ProductivityArea.tsx | 3 +- .../command-center/areas/SignalsArea.tsx | 7 +- .../command-center/areas/TeamArea.tsx | 1 + .../command-center/areas/TokensArea.tsx | 3 +- .../command-center/areas/ToolsArea.tsx | 4 +- .../command-center/areas/WorkflowArea.tsx | 6 +- .../areas/__tests__/TokensArea.test.tsx | 12 ++ .../areas/__tests__/WorkflowArea.test.tsx | 16 ++ .../__tests__/areas.github-signals.test.tsx | 30 +++ .../__tests__/areas.gitlab-signals.test.tsx | 16 ++ .../areas/__tests__/areas.test.tsx | 111 +++++++++++ .../command-center/areas/useAnalyticsArea.ts | 9 +- 27 files changed, 405 insertions(+), 159 deletions(-) diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 18c1ab472..f5b49008c 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -6214,7 +6214,7 @@ export interface AgentPromptSizePoint { totalChars: number; } -function withProjectId(path: string, projectId?: string): string { +export function withProjectId(path: string, projectId?: string): string { if (!projectId) return path; const separator = path.includes("?") ? "&" : "?"; return `${path}${separator}projectId=${encodeURIComponent(projectId)}`; diff --git a/packages/dashboard/app/components/ReliabilityView.tsx b/packages/dashboard/app/components/ReliabilityView.tsx index 43c63c901..b71f9a0d4 100644 --- a/packages/dashboard/app/components/ReliabilityView.tsx +++ b/packages/dashboard/app/components/ReliabilityView.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertCircle, Loader2 } from "lucide-react"; +import { api, withProjectId } from "../api/legacy"; import { LineChart, PieChart } from "./command-center/charts/recharts"; import type { LineChartSeries, PieChartDatum } from "./command-center/charts/recharts"; import "./ReliabilityView.css"; @@ -42,7 +43,7 @@ function formatDateTime(value: string | null | undefined): string { return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString(); } -export function ReliabilityView() { +export function ReliabilityView({ projectId }: { projectId?: string } = {}) { const { t } = useTranslation("app"); const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -55,28 +56,21 @@ export function ReliabilityView() { setIsLoading(true); setError(null); try { - const response = await fetch("/api/health/reliability"); - if (!response.ok) { - throw new Error(`Failed to load reliability metrics (${response.status})`); - } - const payload = (await response.json()) as ReliabilityResponse; + const payload = await api(withProjectId("/health/reliability", projectId)); setData(payload); } catch (loadError: unknown) { setError(loadError instanceof Error ? loadError.message : t("reliability.failedToLoad", "Failed to load reliability metrics")); } finally { setIsLoading(false); } - }, [t]); + }, [t, projectId]); const resetStats = useCallback(async () => { setResetError(null); - const response = await fetch("/api/health/reliability/reset", { method: "POST" }); - if (!response.ok) { - throw new Error(`Failed to reset reliability metrics (${response.status})`); - } + await api(withProjectId("/health/reliability/reset", projectId), { method: "POST" }); await load(); setShowResetConfirm(false); - }, [load]); + }, [load, projectId]); useEffect(() => { void load(); diff --git a/packages/dashboard/app/components/__tests__/ReliabilityView.test.tsx b/packages/dashboard/app/components/__tests__/ReliabilityView.test.tsx index 6576f5204..373d1203b 100644 --- a/packages/dashboard/app/components/__tests__/ReliabilityView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ReliabilityView.test.tsx @@ -1,6 +1,13 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +const apiMock = vi.fn(); +vi.mock("../../api/legacy", () => ({ + api: (path: string, opts?: RequestInit) => apiMock(path, opts), + withProjectId: (path: string, projectId?: string) => + projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, +})); + import { ReliabilityView } from "../ReliabilityView"; const baseResponse = { @@ -32,13 +39,13 @@ const baseResponse = { mergeAttempts: { mean: 1.2, max: 2, histogram: { "1": 1 } }, }; -function renderInProjectContent() { +function renderInProjectContent(projectId?: string) { return render(
- +
, ); } @@ -63,10 +70,11 @@ describe("ReliabilityView", () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + apiMock.mockReset(); }); it("shows loading spinner while data is loading", () => { - vi.spyOn(globalThis, "fetch").mockReturnValue(new Promise(() => {})); + apiMock.mockReturnValue(new Promise(() => {})); render(); @@ -75,8 +83,8 @@ describe("ReliabilityView", () => { expect(screen.queryByRole("heading", { name: "Reliability" })).not.toBeInTheDocument(); }); - it("shows error message when fetch fails", async () => { - vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network unavailable")); + it("shows error message when the api client rejects", async () => { + apiMock.mockRejectedValue(new Error("Network unavailable")); render(); @@ -85,12 +93,27 @@ describe("ReliabilityView", () => { expect(screen.queryByRole("heading", { name: "Reliability" })).not.toBeInTheDocument(); }); + it("uses the shared api() client (carrying auth) instead of raw fetch, and appends projectId when supplied", async () => { + apiMock.mockResolvedValue(baseResponse); + + const { unmount } = render(); + await waitFor(() => expect(apiMock).toHaveBeenCalled()); + expect(apiMock.mock.calls[0]?.[0]).toBe("/health/reliability"); + unmount(); + + apiMock.mockClear(); + apiMock.mockResolvedValue(baseResponse); + render(); + await waitFor(() => expect(apiMock).toHaveBeenCalled()); + expect(apiMock.mock.calls[0]?.[0]).toBe("/health/reliability?projectId=proj-xyz"); + }); + it.each([ ["desktop", 1024], ["mobile", 375], ])("fills flex parent width and height in %s loading state", (_label, width) => { setViewportWidth(width); - vi.spyOn(globalThis, "fetch").mockReturnValue(new Promise(() => {})); + apiMock.mockReturnValue(new Promise(() => {})); renderInProjectContent(); @@ -102,7 +125,7 @@ describe("ReliabilityView", () => { ["mobile", 375], ])("fills flex parent width and height in %s populated state", async (_label, width) => { setViewportWidth(width); - vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => baseResponse } as Response); + apiMock.mockResolvedValue(baseResponse); const { container } = renderInProjectContent(); @@ -115,7 +138,7 @@ describe("ReliabilityView", () => { ["mobile", 375], ])("fills flex parent width and height in %s error state", async (_label, width) => { setViewportWidth(width); - vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network unavailable")); + apiMock.mockRejectedValue(new Error("Network unavailable")); renderInProjectContent(); @@ -125,9 +148,9 @@ describe("ReliabilityView", () => { it("shows data after successful load even if loading refresh is pending", async () => { vi.useFakeTimers(); - const fetchSpy = vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce({ ok: true, json: async () => baseResponse } as Response) - .mockReturnValueOnce(new Promise(() => {})); + apiMock + .mockResolvedValueOnce(baseResponse) + .mockReturnValueOnce(new Promise(() => {})); render(); await act(async () => { @@ -140,7 +163,7 @@ describe("ReliabilityView", () => { vi.advanceTimersByTime(60_000); }); - expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(apiMock).toHaveBeenCalledTimes(2); expect(screen.getByText("80.0%")).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Reliability" })).toBeInTheDocument(); expect(screen.queryByTestId("reliability-loading")).not.toBeInTheDocument(); @@ -148,7 +171,7 @@ describe("ReliabilityView", () => { }); it("renders headline percent and details disclosure", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => baseResponse } as Response); + apiMock.mockResolvedValue(baseResponse); render(); await waitFor(() => expect(screen.getByText("80.0%")).toBeInTheDocument()); @@ -157,7 +180,7 @@ describe("ReliabilityView", () => { }); it("hides zero-sample days by default and reveals with toggle", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => baseResponse } as Response); + apiMock.mockResolvedValue(baseResponse); render(); await waitFor(() => expect(screen.getByText("2026-05-13")).toBeInTheDocument()); @@ -168,7 +191,7 @@ describe("ReliabilityView", () => { }); it("renders the in-review flow chart for populated reliability data", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => baseResponse } as Response); + apiMock.mockResolvedValue(baseResponse); render(); await waitFor(() => expect(screen.getByRole("img", { name: "In-review entered vs bounced per day" })).toBeInTheDocument()); @@ -176,7 +199,7 @@ describe("ReliabilityView", () => { }); it("renders the merge-attempts chart for populated reliability data", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => baseResponse } as Response); + apiMock.mockResolvedValue(baseResponse); render(); await waitFor(() => expect(screen.getByRole("img", { name: "Merge attempts histogram" })).toBeInTheDocument()); @@ -184,15 +207,12 @@ describe("ReliabilityView", () => { }); it("renders chart empty states without throwing when reliability series are empty", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async () => ({ - ...baseResponse, - headline: { inReviewFailureRate7d: null, reason: "no-in-review-entries" }, - perDay: [], - mergeAttempts: { mean: null, max: null, histogram: {}, reason: "no-audit-coverage" }, - }), - } as Response); + apiMock.mockResolvedValue({ + ...baseResponse, + headline: { inReviewFailureRate7d: null, reason: "no-in-review-entries" }, + perDay: [], + mergeAttempts: { mean: null, max: null, histogram: {}, reason: "no-audit-coverage" }, + }); render(); @@ -203,32 +223,29 @@ describe("ReliabilityView", () => { }); it("keeps the flow chart source consistent with the Show empty days table toggle", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async () => ({ - ...baseResponse, - perDay: [ - { - date: "2026-05-12", - tasksEnteredInReview: 4, - tasksBouncedToInProgress: 1, - postMergeAuditFailures: null, - fileScopeInvariantFailures: null, - recoverAlreadyMergedReviewTasksRecoveries: null, - hasSamples: false, - }, - { - date: "2026-05-13", - tasksEnteredInReview: 0, - tasksBouncedToInProgress: 0, - postMergeAuditFailures: null, - fileScopeInvariantFailures: null, - recoverAlreadyMergedReviewTasksRecoveries: null, - hasSamples: true, - }, - ], - }), - } as Response); + apiMock.mockResolvedValue({ + ...baseResponse, + perDay: [ + { + date: "2026-05-12", + tasksEnteredInReview: 4, + tasksBouncedToInProgress: 1, + postMergeAuditFailures: null, + fileScopeInvariantFailures: null, + recoverAlreadyMergedReviewTasksRecoveries: null, + hasSamples: false, + }, + { + date: "2026-05-13", + tasksEnteredInReview: 0, + tasksBouncedToInProgress: 0, + postMergeAuditFailures: null, + fileScopeInvariantFailures: null, + recoverAlreadyMergedReviewTasksRecoveries: null, + hasSamples: true, + }, + ], + }); render(); @@ -241,13 +258,13 @@ describe("ReliabilityView", () => { expect(within(screen.getByTestId("reliability-flow-chart")).queryByText("No in-review flow data")).not.toBeInTheDocument(); }); - it("opens reset modal and confirms reset with refetch", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce({ ok: true, json: async () => baseResponse } as Response) - .mockResolvedValueOnce({ ok: true, json: async () => ({ resetAt: "2026-05-13T01:00:00.000Z" }) } as Response) - .mockResolvedValueOnce({ ok: true, json: async () => ({ ...baseResponse, resetAt: "2026-05-13T01:00:00.000Z" }) } as Response); + it("opens reset modal and confirms reset with refetch, both carrying projectId", async () => { + apiMock + .mockResolvedValueOnce(baseResponse) + .mockResolvedValueOnce({ resetAt: "2026-05-13T01:00:00.000Z" }) + .mockResolvedValueOnce({ ...baseResponse, resetAt: "2026-05-13T01:00:00.000Z" }); - render(); + render(); await waitFor(() => expect(screen.getByText("80.0%")).toBeInTheDocument()); fireEvent.click(screen.getByRole("button", { name: "Reset stats" })); @@ -255,13 +272,13 @@ describe("ReliabilityView", () => { fireEvent.click(screen.getByRole("button", { name: "Confirm reset" })); await waitFor(() => { - expect(fetchSpy).toHaveBeenCalledWith("/api/health/reliability/reset", { method: "POST" }); + expect(apiMock).toHaveBeenCalledWith("/health/reliability/reset?projectId=proj-reset", { method: "POST" }); }); await waitFor(() => expect(screen.getByText(/Counting since/)).toBeInTheDocument()); }); it("renders duration more-stats raw metrics", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => baseResponse } as Response); + apiMock.mockResolvedValue(baseResponse); render(); await waitFor(() => expect(screen.getByText("80.0%")).toBeInTheDocument()); @@ -271,7 +288,7 @@ describe("ReliabilityView", () => { }); it("FN-4594: root container provides vertical scroll contract", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => ({ ...baseResponse, perDay: [] }) } as Response); + apiMock.mockResolvedValue({ ...baseResponse, perDay: [] }); const { container } = render(); await waitFor(() => expect(container.querySelector(".reliability-view")).not.toBeNull()); @@ -283,39 +300,33 @@ describe("ReliabilityView", () => { }); it("FN-4716: renders 100.0% when zero bounces", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async () => ({ - ...baseResponse, - headline: { inReviewFailureRate7d: 0 }, - perDay: [ - { - date: "2026-05-13", - tasksEnteredInReview: 5, - tasksBouncedToInProgress: 0, - postMergeAuditFailures: null, - fileScopeInvariantFailures: null, - recoverAlreadyMergedReviewTasksRecoveries: null, - hasSamples: true, - }, - ], - }), - } as Response); + apiMock.mockResolvedValue({ + ...baseResponse, + headline: { inReviewFailureRate7d: 0 }, + perDay: [ + { + date: "2026-05-13", + tasksEnteredInReview: 5, + tasksBouncedToInProgress: 0, + postMergeAuditFailures: null, + fileScopeInvariantFailures: null, + recoverAlreadyMergedReviewTasksRecoveries: null, + hasSamples: true, + }, + ], + }); render(); await waitFor(() => expect(screen.getByText("100.0%")).toBeInTheDocument()); }); it("renders null headline reason gracefully", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async () => ({ - ...baseResponse, - headline: { inReviewFailureRate7d: null, reason: "no-in-review-entries" }, - duration: { p50Ms: null, p95Ms: null, sampleCount: 0, reason: "insufficient-samples" }, - mergeAttempts: { mean: null, max: null, histogram: {}, reason: "no-audit-coverage" }, - }), - } as Response); + apiMock.mockResolvedValue({ + ...baseResponse, + headline: { inReviewFailureRate7d: null, reason: "no-in-review-entries" }, + duration: { p50Ms: null, p95Ms: null, sampleCount: 0, reason: "insufficient-samples" }, + mergeAttempts: { mean: null, max: null, histogram: {}, reason: "no-audit-coverage" }, + }); render(); await waitFor(() => expect(screen.getByText("Insufficient data — no-in-review-entries")).toBeInTheDocument()); diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index d8ed8a531..8e1d9dfc8 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertCircle, Gauge } from "lucide-react"; import type { ActivityAnalytics, ColorTheme, LiveSnapshot, SignalsAnalytics, ThemeMode, TokenAnalytics, ToolAnalytics } from "@fusion/core"; -import { api } from "../../api/legacy"; +import { api, withProjectId } from "../../api/legacy"; import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker"; import { LoadingSpinner } from "../LoadingSpinner"; import { TokensArea } from "./areas/TokensArea"; @@ -149,10 +149,11 @@ function OverviewTab({ const { t } = useTranslation("app"); const tokens = useAnalyticsArea("/command-center/tokens?groupBy=model", range, { pollMs: OVERVIEW_TOKEN_REFRESH_MS, + projectId, }); - const tools = useAnalyticsArea("/command-center/tools", range); - const activity = useAnalyticsArea("/command-center/activity", range); - const signals = useAnalyticsArea("/command-center/signals", range); + const tools = useAnalyticsArea("/command-center/tools", range, { projectId }); + const activity = useAnalyticsArea("/command-center/activity", range, { projectId }); + const signals = useAnalyticsArea("/command-center/signals", range, { projectId }); const [liveSnapshot, setLiveSnapshot] = useState(null); const [liveSnapshotLoading, setLiveSnapshotLoading] = useState(true); @@ -161,7 +162,7 @@ function OverviewTab({ setLiveSnapshotLoading(true); void (async () => { try { - const result = await api("/command-center/live"); + const result = await api(withProjectId("/command-center/live", projectId)); if (!cancelled) { setLiveSnapshot(result); } @@ -178,7 +179,7 @@ function OverviewTab({ return () => { cancelled = true; }; - }, []); + }, [projectId]); const tokenTotal = tokens.data?.totals?.totalTokens ?? 0; const toolCalls = tools.data?.toolCalls ?? 0; @@ -310,7 +311,7 @@ function OverviewTab({ ); const throughputSection = (
- +
); @@ -562,33 +563,33 @@ export function CommandCenter({ /> ); case "tokens": - return ; + return ; case "tools": - return ; + return ; case "activity": - return ; + return ; case "productivity": - return ; + return ; case "team": return ; case "workflows": - return ; + return ; case "ecosystem": - return ; + return ; case "github": - return ; + return ; case "gitlab": - return ; + return ; case "signals": - return ; + return ; case "system": return ; case "nodes": return ; case "reliability": - return ; + return ; case "mission-control": - return ; + return ; default: return ; } diff --git a/packages/dashboard/app/components/command-center/MissionControlPanel.tsx b/packages/dashboard/app/components/command-center/MissionControlPanel.tsx index aee96db44..95f729ade 100644 --- a/packages/dashboard/app/components/command-center/MissionControlPanel.tsx +++ b/packages/dashboard/app/components/command-center/MissionControlPanel.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertCircle, Loader2, Radio } from "lucide-react"; import type { LiveSnapshot, LiveSession, ColumnCount } from "@fusion/core"; -import { api } from "../../api/legacy"; +import { api, withProjectId } from "../../api/legacy"; import { subscribeSse } from "../../sse-bus"; import { Funnel, type FunnelStage } from "./charts/Funnel"; import "./MissionControlPanel.css"; @@ -79,7 +79,7 @@ export interface LiveSnapshotState { * The decision to poll is derived from the freshest snapshot (kept in a ref so the * interval callback always sees current state), re-evaluated after every fetch. */ -export function useLiveSnapshot(): LiveSnapshotState { +export function useLiveSnapshot(projectId?: string): LiveSnapshotState { const [snapshot, setSnapshot] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -106,7 +106,7 @@ export function useLiveSnapshot(): LiveSnapshotState { if (inFlightRef.current) return; inFlightRef.current = true; try { - const result = await api("/command-center/live"); + const result = await api(withProjectId("/command-center/live", projectId)); if (!mountedRef.current) return; snapshotRef.current = result; setSnapshot(result); @@ -135,7 +135,7 @@ export function useLiveSnapshot(): LiveSnapshotState { } } } - }, [stopPolling]); + }, [stopPolling, projectId]); useEffect(() => { mountedRef.current = true; @@ -230,9 +230,9 @@ function deriveFunnelStages(columns: ColumnCount[], label: (id: string, fallback * `GET /api/command-center/live` with push + poll convergence (KTD5): SSE events * trigger an immediate refetch, and a 5s poll runs only while work is in-flight. */ -export function MissionControlPanel() { +export function MissionControlPanel({ projectId }: { projectId?: string } = {}) { const { t } = useTranslation("app"); - const { snapshot, isLoading, error } = useLiveSnapshot(); + const { snapshot, isLoading, error } = useLiveSnapshot(projectId); const sessions = useMemo(() => snapshot?.sessions ?? [], [snapshot?.sessions]); const nodes = useMemo( diff --git a/packages/dashboard/app/components/command-center/SdlcFunnel.tsx b/packages/dashboard/app/components/command-center/SdlcFunnel.tsx index 9e1f0cd2a..c5f9181ba 100644 --- a/packages/dashboard/app/components/command-center/SdlcFunnel.tsx +++ b/packages/dashboard/app/components/command-center/SdlcFunnel.tsx @@ -59,12 +59,13 @@ function formatThroughput(value: number): string { * (which it derives by workflow trait, not column name), so custom workflow * columns surface correctly and unknown columns appear under "Other". */ -export function SdlcFunnel({ range }: { range: DateRange }) { +export function SdlcFunnel({ range, projectId }: { range: DateRange; projectId?: string }) { const { t } = useTranslation("app"); const labelFor = useStageLabels(); const { data, isLoading, error } = useAnalyticsArea( "/command-center/activity", range, + { projectId }, ); const funnel: SdlcFunnelData | null = data?.funnel ?? null; diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx index f5817b969..38f64a3c7 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx @@ -9,6 +9,8 @@ import { CommandCenter } from "../CommandCenter"; const apiMock = vi.fn(); vi.mock("../../../api/legacy", () => ({ api: (path: string, opts?: RequestInit) => apiMock(path, opts), + withProjectId: (path: string, projectId?: string) => + projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, fetchOrgTree: vi.fn().mockResolvedValue([]), fetchExecutorStats: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 }), fetchSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxTriageConcurrent: 1, maxWorktrees: 5 }), diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx index 6bec5f59e..59767a51d 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx @@ -9,6 +9,8 @@ import { CommandCenter } from "../CommandCenter"; const apiMock = vi.fn(); vi.mock("../../../api/legacy", () => ({ api: (path: string, opts?: RequestInit) => apiMock(path, opts), + withProjectId: (path: string, projectId?: string) => + projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, // TeamArea (rendered on the team tab) imports these directly; provide resolving // mocks so its mount effects (heartbeat-multiplier load/save, org tree, executor // stats) don't call undefined and throw synchronously. diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx index 7f32e208c..83f4f424c 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -9,6 +9,8 @@ import { CommandCenter } from "../CommandCenter"; const apiMock = vi.fn(); vi.mock("../../../api/legacy", () => ({ api: (path: string, opts?: RequestInit) => apiMock(path, opts), + withProjectId: (path: string, projectId?: string) => + projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, fetchOrgTree: vi.fn().mockResolvedValue([]), fetchExecutorStats: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 }), fetchSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxTriageConcurrent: 1, maxWorktrees: 5 }), @@ -1099,24 +1101,23 @@ describe("CommandCenter shell", () => { expect(screen.getByTestId("cc-github-fixed").textContent).toContain("2"); }); - it("renders and routes the Reliability tab exactly once", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async () => reliabilityFixture(), - } as Response); - - try { - render(); - expect(screen.getAllByTestId("command-center-tab-reliability")).toHaveLength(1); - - fireEvent.click(screen.getByTestId("command-center-tab-reliability")); - expect(screen.getByTestId("command-center-tab-reliability").getAttribute("aria-selected")).toBe("true"); - expect(screen.getByTestId("command-center-panel-reliability")).toBeTruthy(); - expect(await screen.findByRole("heading", { name: "Reliability" })).toBeTruthy(); - expect(fetchSpy).toHaveBeenCalledWith("/api/health/reliability"); - } finally { - fetchSpy.mockRestore(); - } + it("renders and routes the Reliability tab exactly once, threading projectId to the shared api() client", async () => { + apiMock.mockImplementation((path: string) => { + if (path.startsWith("/health/reliability")) return Promise.resolve(reliabilityFixture()); + if (path === "/command-center/live" || path.startsWith("/command-center/live?")) return Promise.resolve(liveFixture()); + if (path === "/system-stats") return Promise.resolve(systemStatsFixture()); + if (path === "/settings/global") return Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }); + return Promise.reject(new Error(`Unhandled api path: ${path}`)); + }); + + render(); + expect(screen.getAllByTestId("command-center-tab-reliability")).toHaveLength(1); + + fireEvent.click(screen.getByTestId("command-center-tab-reliability")); + expect(screen.getByTestId("command-center-tab-reliability").getAttribute("aria-selected")).toBe("true"); + expect(screen.getByTestId("command-center-panel-reliability")).toBeTruthy(); + expect(await screen.findByRole("heading", { name: "Reliability" })).toBeTruthy(); + expect(apiMock).toHaveBeenCalledWith("/health/reliability?projectId=project-a", undefined); }); it("renders the Team tab with sortable per-agent stats and charts", async () => { diff --git a/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx b/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx index fe0a3ea04..c9e6de5e4 100644 --- a/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx @@ -6,6 +6,8 @@ import type { LiveSnapshot } from "@fusion/core"; const apiMock = vi.fn(); vi.mock("../../../api/legacy", () => ({ api: (path: string, opts?: RequestInit) => apiMock(path, opts), + withProjectId: (path: string, projectId?: string) => + projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, })); // Mock the SSE bus, capturing the subscription so tests can fire events and @@ -96,6 +98,20 @@ describe("MissionControlPanel — KTD5 push + poll convergence", () => { expect(screen.getByTestId("mission-control-idle")).toBeTruthy(); }); + it("appends projectId to the live snapshot request when supplied, and omits it when not", async () => { + apiMock.mockResolvedValue(snapshot()); + const { unmount } = render(); + await flush(); + expect(apiMock.mock.calls.at(-1)?.[0]).toBe("/command-center/live"); + unmount(); + + apiMock.mockClear(); + apiMock.mockResolvedValue(snapshot()); + render(); + await flush(); + expect(apiMock.mock.calls.at(-1)?.[0]).toBe("/command-center/live?projectId=proj-abc"); + }); + it("does NOT schedule a poll interval when idle (zero active sessions)", async () => { apiMock.mockResolvedValue(snapshot()); // idle from the start const setInterval = vi.spyOn(globalThis, "setInterval"); diff --git a/packages/dashboard/app/components/command-center/__tests__/SdlcFunnel.test.tsx b/packages/dashboard/app/components/command-center/__tests__/SdlcFunnel.test.tsx index 1ca05cd11..0b1161719 100644 --- a/packages/dashboard/app/components/command-center/__tests__/SdlcFunnel.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/SdlcFunnel.test.tsx @@ -5,6 +5,8 @@ import { render, screen, waitFor } from "@testing-library/react"; const apiMock = vi.fn(); vi.mock("../../../api/legacy", () => ({ api: (path: string, opts?: RequestInit) => apiMock(path, opts), + withProjectId: (path: string, projectId?: string) => + projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, })); import { SdlcFunnel } from "../SdlcFunnel"; @@ -53,6 +55,20 @@ beforeEach(() => { }); describe("SdlcFunnel", () => { + it("appends projectId to the activity request when supplied, and omits it when not", async () => { + apiMock.mockResolvedValue(activityFixture(fullFunnel())); + const { unmount } = render(); + await waitFor(() => expect(apiMock).toHaveBeenCalled()); + expect(apiMock.mock.calls.at(-1)?.[0]).toBe("/command-center/activity?from=2026-06-08&projectId=proj-funnel"); + unmount(); + + apiMock.mockClear(); + apiMock.mockResolvedValue(activityFixture(fullFunnel())); + render(); + await waitFor(() => expect(apiMock).toHaveBeenCalled()); + expect(apiMock.mock.calls.at(-1)?.[0]).toBe("/command-center/activity?from=2026-06-08"); + }); + it("fetches the activity endpoint and renders per-stage counts", async () => { apiMock.mockResolvedValue(activityFixture(fullFunnel())); render(); diff --git a/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx b/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx index 695850087..ce739a283 100644 --- a/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/ActivityArea.tsx @@ -20,9 +20,11 @@ The Activity surface must add FN-6682 recharts affordances without replacing the * FNXC:CommandCenter 2026-06-18-14:29: * Activity metrics surface as live, animated line charts auto-refreshed via reload() on a bounded interval; motion is decorative and reduced-motion-safe, uses the existing activity endpoint, and keeps prior data visible during polling revalidation. */ -export function ActivityArea({ range }: { range: DateRange }) { +export function ActivityArea({ range, projectId }: { range: DateRange; projectId?: string }) { const { t } = useTranslation("app"); - const { data, isLoading, error, reload } = useAnalyticsArea("/command-center/activity", range); + const { data, isLoading, error, reload } = useAnalyticsArea("/command-center/activity", range, { + projectId, + }); const daily = useMemo(() => data?.daily ?? [], [data?.daily]); const messagesSeries = useMemo(() => daily.map((d) => d.messages), [daily]); diff --git a/packages/dashboard/app/components/command-center/areas/EcosystemArea.tsx b/packages/dashboard/app/components/command-center/areas/EcosystemArea.tsx index 5d87acf9c..f9d081910 100644 --- a/packages/dashboard/app/components/command-center/areas/EcosystemArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/EcosystemArea.tsx @@ -21,15 +21,17 @@ import { formatCount } from "./areaShared"; * FNXC:CommandCenterEcosystem 2026-06-19-08:10: * Plugin activations now come from recorded load/reload events. Show a real count only when the activation endpoint reports in-range rows; no rows, loading, or plugin-analytics errors must keep the honest `—` sentinel. */ -export function EcosystemArea({ range }: { range: DateRange }) { +export function EcosystemArea({ range, projectId }: { range: DateRange; projectId?: string }) { const { t } = useTranslation("app"); const { data, isLoading, error } = useAnalyticsArea( "/command-center/tokens?groupBy=model&granularity=day", range, + { projectId }, ); const { data: pluginActivations, isLoading: pluginActivationsLoading } = useAnalyticsArea( "/command-center/plugin-activations", range, + { projectId }, ); const models = useMemo( diff --git a/packages/dashboard/app/components/command-center/areas/GithubArea.tsx b/packages/dashboard/app/components/command-center/areas/GithubArea.tsx index a90488463..d09693404 100644 --- a/packages/dashboard/app/components/command-center/areas/GithubArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/GithubArea.tsx @@ -30,11 +30,12 @@ function formatResolvedAt(value: string, fallback: string): string { return date.toLocaleString(); } -export function GithubArea({ range }: { range: DateRange }) { +export function GithubArea({ range, projectId }: { range: DateRange; projectId?: string }) { const { t } = useTranslation("app"); const { data, isLoading, error } = useAnalyticsArea( "/command-center/github", range, + { projectId }, ); const [isBackfilling, setIsBackfilling] = useState(false); const [backfillResult, setBackfillResult] = useState(null); diff --git a/packages/dashboard/app/components/command-center/areas/GitlabArea.tsx b/packages/dashboard/app/components/command-center/areas/GitlabArea.tsx index 55ced4ac4..a7141ff60 100644 --- a/packages/dashboard/app/components/command-center/areas/GitlabArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/GitlabArea.tsx @@ -18,11 +18,12 @@ function formatResolvedAt(value: string, fallback: string): string { return date.toLocaleString(); } -export function GitlabArea({ range }: { range: DateRange }) { +export function GitlabArea({ range, projectId }: { range: DateRange; projectId?: string }) { const { t } = useTranslation("app"); const { data, isLoading, error } = useAnalyticsArea( "/command-center/gitlab", range, + { projectId }, ); const daily = useMemo(() => data?.daily ?? [], [data?.daily]); diff --git a/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx b/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx index 7015be690..d20c0d73c 100644 --- a/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx @@ -41,12 +41,13 @@ ProductivityAnalytics exposes a categorical language distribution but no per-day * FNXC:CommandCenterProductivity 2026-06-20-03:41: * Every productivity metric sub-object is defaulted to the unavailable sentinel so a partial or contract-incomplete payload, including future field additions, renders "—" instead of throwing an uncaught error that crashes Command Center and pollutes the shared jsdom worker. */ -export function ProductivityArea({ range }: { range: DateRange }) { +export function ProductivityArea({ range, projectId }: { range: DateRange; projectId?: string }) { const { t } = useTranslation("app"); const { confirm } = useConfirm(); const { data, isLoading, error } = useAnalyticsArea( "/command-center/productivity", range, + { projectId }, ); const [isBackfilling, setIsBackfilling] = useState(false); const [backfillReport, setBackfillReport] = useState(null); diff --git a/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx b/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx index c639e6b7f..783b47592 100644 --- a/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/SignalsArea.tsx @@ -38,12 +38,15 @@ FNXC:CommandCenterSignals 2026-06-25-22:40: Empty Signals copy is driven by the connectors-status endpoint, not fabricated metrics. Operators must see "no connector configured" when no HMAC secret exists and "configured, awaiting signals" when ingestion is ready but quiet; loading the status should keep the normal loading-before-empty behavior. */ -export function SignalsArea({ range }: { range: DateRange }) { +export function SignalsArea({ range, projectId }: { range: DateRange; projectId?: string }) { const { t } = useTranslation("app"); - const { data, isLoading } = useAnalyticsArea("/command-center/signals", range); + const { data, isLoading } = useAnalyticsArea("/command-center/signals", range, { + projectId, + }); const { data: connectorsData, isLoading: isConnectorsLoading } = useAnalyticsArea( "/command-center/signals/connectors", range, + { projectId }, ); const sourceBars = useMemo( diff --git a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx index 42a7518b6..d5f4de379 100644 --- a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx @@ -207,6 +207,7 @@ export function TeamArea({ const [orgChartViewportWidth, setOrgChartViewportWidth] = useState(0); const { data, isLoading, error } = useAnalyticsArea("/command-center/team", range, { pollMs: TEAM_LIVE_REFRESH_MS, + projectId, }); useEffect(() => { diff --git a/packages/dashboard/app/components/command-center/areas/TokensArea.tsx b/packages/dashboard/app/components/command-center/areas/TokensArea.tsx index 1a07b2b80..358d12360 100644 --- a/packages/dashboard/app/components/command-center/areas/TokensArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/TokensArea.tsx @@ -90,12 +90,13 @@ function sortGroups(groups: TokenGroupSummary[], key: SortKey, dir: 1 | -1): Tok * Tokens area: per-model token totals + derived USD cost, plus a bar chart of * tokens by model. Grouped by model via the `?groupBy=model` endpoint param. */ -export function TokensArea({ range }: { range: DateRange }) { +export function TokensArea({ range, projectId }: { range: DateRange; projectId?: string }) { const { t } = useTranslation("app"); const [granularity, setGranularity] = useState("day"); const endpoint = `/command-center/tokens?groupBy=model&granularity=${granularity}`; const { data, isLoading, error } = useAnalyticsArea(endpoint, range, { pollMs: TOKENS_LIVE_REFRESH_MS, + projectId, }); const groups = useMemo(() => data?.groups ?? [], [data?.groups]); diff --git a/packages/dashboard/app/components/command-center/areas/ToolsArea.tsx b/packages/dashboard/app/components/command-center/areas/ToolsArea.tsx index 125278498..8eab8d84d 100644 --- a/packages/dashboard/app/components/command-center/areas/ToolsArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/ToolsArea.tsx @@ -23,9 +23,9 @@ The Tools surface has categorical tool-call analytics but no per-day tool trend * Tools area of the Command Center (PR #1683). Shows the autonomy ratio plus tool-category usage; display * order is re-sorted client-side so it never silently depends on server ordering. */ -export function ToolsArea({ range }: { range: DateRange }) { +export function ToolsArea({ range, projectId }: { range: DateRange; projectId?: string }) { const { t } = useTranslation("app"); - const { data, isLoading, error } = useAnalyticsArea("/command-center/tools", range); + const { data, isLoading, error } = useAnalyticsArea("/command-center/tools", range, { projectId }); const sortedCategories = useMemo( () => [...(data?.byCategory ?? [])].sort((a, b) => b.count - a.count || a.category.localeCompare(b.category)), diff --git a/packages/dashboard/app/components/command-center/areas/WorkflowArea.tsx b/packages/dashboard/app/components/command-center/areas/WorkflowArea.tsx index d29ae8b2b..885f01a50 100644 --- a/packages/dashboard/app/components/command-center/areas/WorkflowArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/WorkflowArea.tsx @@ -64,9 +64,11 @@ function buildBarData( }); } -export function WorkflowArea({ range }: { range: DateRange }) { +export function WorkflowArea({ range, projectId }: { range: DateRange; projectId?: string }) { const { t } = useTranslation("app"); - const { data, isLoading, error } = useAnalyticsArea("/command-center/workflows", range); + const { data, isLoading, error } = useAnalyticsArea("/command-center/workflows", range, { + projectId, + }); const unknownWorkflow = t("commandCenter.workflows.unknownWorkflow", "(unknown workflow)"); const noChartData = t("commandCenter.workflows.noChartData", "No non-zero values for this chart yet."); diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx index 830017adf..da841b65e 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx @@ -7,6 +7,8 @@ const apiMock = vi.fn(); vi.mock("../../../../api/legacy", () => ({ api: (path: string, opts?: RequestInit) => apiMock(path, opts), + withProjectId: (path: string, projectId?: string) => + projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, })); vi.mock("../../../ProviderIcon", () => ({ @@ -279,3 +281,13 @@ describe("TokensArea provider model icons", () => { expect(screen.getByTestId("cc-tokens-row-(unknown)")).toHaveTextContent("(unknown)"); }); }); + +describe("FUX-037: TokensArea projectId scoping", () => { + it("appends projectId to the tokens request when supplied, and omits it when not", async () => { + render(); + await screen.findByTestId("cc-tokens-table"); + expect(apiMock.mock.calls.at(-1)?.[0]).toBe( + "/command-center/tokens?groupBy=model&granularity=day&from=2026-06-08&projectId=proj-tokens", + ); + }); +}); diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/WorkflowArea.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/WorkflowArea.test.tsx index 30427f6aa..6bf73566a 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/WorkflowArea.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/WorkflowArea.test.tsx @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ vi.mock("../../../../api/legacy", () => ({ api: (path: string, opts?: RequestInit) => mocks.api(path, opts), + withProjectId: (path: string, projectId?: string) => + projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, })); import { WorkflowArea } from "../WorkflowArea"; @@ -73,6 +75,20 @@ beforeEach(() => { }); describe("WorkflowArea", () => { + it("appends projectId to its workflows request when supplied, and omits it when not", async () => { + mocks.api.mockResolvedValue(emptyWorkflowFixture()); + const { unmount } = render(); + await screen.findByTestId("cc-area-workflows-empty"); + expect(mocks.api.mock.calls.at(-1)?.[0]).toBe("/command-center/workflows?from=2026-06-08&projectId=proj-wf"); + unmount(); + + mocks.api.mockClear(); + mocks.api.mockResolvedValue(emptyWorkflowFixture()); + render(); + await screen.findByTestId("cc-area-workflows-empty"); + expect(mocks.api.mock.calls.at(-1)?.[0]).toBe("/command-center/workflows?from=2026-06-08"); + }); + it("renders loading, empty, and error states through AreaShell", async () => { let resolveWorkflows: (value: unknown) => void = () => undefined; mocks.api.mockReturnValueOnce(new Promise((resolve) => { resolveWorkflows = resolve; })); diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.github-signals.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.github-signals.test.tsx index 6319c721c..1659ac6e8 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.github-signals.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.github-signals.test.tsx @@ -26,6 +26,8 @@ const toggleEnginePauseMock = mocks.toggleEnginePause; const appSettingsMock = mocks.appSettings; vi.mock("../../../../api/legacy", () => ({ api: (path: string, opts?: RequestInit) => mocks.api(path, opts), + withProjectId: (path: string, projectId?: string) => + projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, apiBackfillGithubSourceIssueClosedAt: (options?: { offset?: number; limit?: number }, projectId?: string) => mocks.backfillGithubSourceIssueClosedAt(options, projectId), backfillCommitAssociationDiffStats: (options?: { dryRun?: boolean }, projectId?: string) => @@ -77,6 +79,20 @@ afterEach(() => { }); describe("GithubArea", () => { + it("appends projectId to its github request when supplied, and omits it when not", async () => { + apiMock.mockResolvedValue(githubFixture()); + const { unmount } = render(); + await screen.findByTestId("cc-area-github"); + expect(apiMock.mock.calls.at(-1)?.[0]).toBe("/command-center/github?from=2026-06-08&projectId=proj-gh"); + unmount(); + + apiMock.mockClear(); + apiMock.mockResolvedValue(githubFixture()); + render(); + await screen.findByTestId("cc-area-github"); + expect(apiMock.mock.calls.at(-1)?.[0]).toBe("/command-center/github?from=2026-06-08"); + }); + it("renders filed/fixed/net stats, daily trend, and by-repo bars", async () => { apiMock.mockResolvedValue(githubFixture()); render(); @@ -357,6 +373,20 @@ function mockSignalsResponses(signals: unknown, connectors: unknown): void { } describe("SignalsArea", () => { + it("appends projectId to both its signals requests when supplied", async () => { + apiMock.mockResolvedValue({ + open: 0, + totalSignals: 0, + resolved: 0, + bySource: [], + byType: [], + mttr: { value: null, unavailable: true }, + }); + render(); + await waitFor(() => expect(apiMock).toHaveBeenCalledTimes(2)); + expect(apiMock.mock.calls.every(([path]) => typeof path === "string" && path.includes("projectId=proj-sig"))).toBe(true); + }); + it("renders the empty state (not an error) when the signals endpoint is missing", async () => { apiMock.mockRejectedValue(new Error("API returned HTML instead of JSON (404)")); render(); diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.gitlab-signals.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.gitlab-signals.test.tsx index 8cb5a2d92..c81dc212a 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.gitlab-signals.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.gitlab-signals.test.tsx @@ -4,6 +4,8 @@ import { render, screen, within } from "@testing-library/react"; const mocks = vi.hoisted(() => ({ api: vi.fn() })); vi.mock("../../../../api/legacy", () => ({ api: (path: string, opts?: RequestInit) => mocks.api(path, opts), + withProjectId: (path: string, projectId?: string) => + projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, })); import { GitlabArea } from "../GitlabArea"; @@ -52,6 +54,20 @@ beforeEach(() => { }); describe("GitlabArea", () => { + it("appends projectId to its gitlab request when supplied, and omits it when not", async () => { + mocks.api.mockResolvedValue(gitlabFixture()); + const { unmount } = render(); + await screen.findByTestId("cc-area-gitlab"); + expect(mocks.api.mock.calls.at(-1)?.[0]).toBe("/command-center/gitlab?from=2026-06-08&projectId=proj-gl"); + unmount(); + + mocks.api.mockClear(); + mocks.api.mockResolvedValue(gitlabFixture()); + render(); + await screen.findByTestId("cc-area-gitlab"); + expect(mocks.api.mock.calls.at(-1)?.[0]).toBe("/command-center/gitlab?from=2026-06-08"); + }); + it("renders GitLab analytics from local dashboard API data only", async () => { mocks.api.mockResolvedValue(gitlabFixture()); diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index 9eeb52312..73526ebeb 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -31,6 +31,8 @@ const toggleEnginePauseMock = mocks.toggleEnginePause; const appSettingsMock = mocks.appSettings; vi.mock("../../../../api/legacy", () => ({ api: (path: string, opts?: RequestInit) => mocks.api(path, opts), + withProjectId: (path: string, projectId?: string) => + projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, apiBackfillGithubSourceIssueClosedAt: (options?: { offset?: number; limit?: number }, projectId?: string) => mocks.backfillGithubSourceIssueClosedAt(options, projectId), backfillCommitAssociationDiffStats: (options?: { dryRun?: boolean }, projectId?: string) => @@ -175,6 +177,27 @@ describe("useAnalyticsArea", () => { expect(apiMock).toHaveBeenCalledTimes(1); }); + it("appends projectId to the request path when supplied, and omits it when not", async () => { + apiMock.mockResolvedValue({ ok: true }); + + const { rerender } = renderHook( + ({ projectId }: { projectId?: string }) => + useAnalyticsArea<{ ok: boolean }>("/command-center/tokens", range7d, { projectId }), + { initialProps: { projectId: undefined as string | undefined } }, + ); + + await act(async () => { + await Promise.resolve(); + }); + expect(apiMock.mock.calls.at(-1)?.[0]).toBe("/command-center/tokens?from=2026-06-08"); + + rerender({ projectId: "proj-123" }); + await act(async () => { + await Promise.resolve(); + }); + expect(apiMock.mock.calls.at(-1)?.[0]).toBe("/command-center/tokens?from=2026-06-08&projectId=proj-123"); + }); + it("refetches with distinct request keys for each default preset", async () => { vi.useFakeTimers({ now: new Date("2026-06-15T12:00:00.000Z") }); apiMock.mockResolvedValue({ ok: true }); @@ -1508,3 +1531,91 @@ describe("EcosystemArea", () => { expect(screen.getByTestId("cc-area-ecosystem").textContent).not.toContain("Infinity"); }); }); + +/* +FNXC:CommandCenter 2026-07-08-00:00: +FUX-037 regression: every Command Center area must thread a supplied projectId prop +through to its underlying api() request path (fixing the always-queries-wrong-project +bug), and must omit the param entirely when projectId is not supplied (no regression +for legacy/no-project contexts). +*/ +describe("FUX-037: projectId scoping across Command Center areas", () => { + it("TokensArea appends projectId to its tokens request", async () => { + apiMock.mockResolvedValue(tokenFixture()); + render(); + await waitFor(() => expect(apiMock).toHaveBeenCalled()); + expect(apiMock.mock.calls.some(([path]) => typeof path === "string" && path.includes("projectId=proj-1"))).toBe(true); + + apiMock.mockClear(); + apiMock.mockResolvedValue(tokenFixture()); + render(); + await waitFor(() => expect(apiMock).toHaveBeenCalled()); + expect(apiMock.mock.calls.every(([path]) => typeof path === "string" && !path.includes("projectId"))).toBe(true); + }); + + it("ToolsArea appends projectId to its tools request", async () => { + apiMock.mockResolvedValue({ + toolCalls: 0, + autonomyRatio: 0, + fullyAutonomous: false, + byCategory: [], + interventions: { approvals: 0, userSteers: 0, total: 0 }, + }); + render(); + await waitFor(() => + expect(apiMock).toHaveBeenCalledWith("/command-center/tools?from=2026-06-08&projectId=proj-2", undefined), + ); + }); + + it("ActivityArea appends projectId to its activity request", async () => { + apiMock.mockResolvedValue(activityFixture()); + render(); + await waitFor(() => + expect(apiMock).toHaveBeenCalledWith("/command-center/activity?from=2026-06-08&projectId=proj-3", undefined), + ); + }); + + it("ProductivityArea appends projectId to its productivity request", async () => { + apiMock.mockResolvedValue(productivityFixture()); + render(); + await waitFor(() => + expect(apiMock).toHaveBeenCalledWith("/command-center/productivity?from=2026-06-08&projectId=proj-4", undefined), + ); + }); + + it("EcosystemArea appends projectId to both its requests", async () => { + apiMock.mockResolvedValue(tokenFixture()); + render(); + await waitFor(() => expect(apiMock).toHaveBeenCalledTimes(2)); + expect(apiMock.mock.calls.every(([path]) => typeof path === "string" && path.includes("projectId=proj-5"))).toBe(true); + }); + + it("TeamArea appends projectId to its team analytics request (already-received prop, previously missed)", async () => { + apiMock.mockResolvedValue(emptyTeamFixture()); + render( + + + , + ); + await waitFor(() => + expect(apiMock).toHaveBeenCalledWith("/command-center/team?from=2026-06-08&projectId=proj-6", undefined), + ); + }); + + it("renders distinct fixture data for two different projects without cross-project leakage", async () => { + const fixtureA = tokenFixture(1_000); + const fixtureB = tokenFixture(9_000); + apiMock.mockImplementation((path: string) => { + if (path.includes("projectId=proj-a")) return Promise.resolve(fixtureA); + if (path.includes("projectId=proj-b")) return Promise.resolve(fixtureB); + return Promise.reject(new Error(`unexpected path in two-project test: ${path}`)); + }); + + const { rerender } = render(); + await screen.findByTestId("cc-area-tokens"); + expect(screen.getByTestId("cc-area-tokens").textContent).toContain("1"); + + rerender(); + await waitFor(() => expect(apiMock.mock.calls.some(([path]) => typeof path === "string" && path.includes("projectId=proj-b"))).toBe(true)); + }); +}); diff --git a/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts b/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts index 8c0c8f7b1..007880047 100644 --- a/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts +++ b/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { api } from "../../../api/legacy"; +import { api, withProjectId } from "../../../api/legacy"; import type { DateRange } from "../DateRangePicker"; import { isInvalidRange, rangeQuery } from "./areaShared"; @@ -14,6 +14,8 @@ export interface AnalyticsAreaState { export interface AnalyticsAreaOptions { /** Opt-in bounded polling interval in milliseconds; omitted means no polling. */ pollMs?: number; + /** Currently-selected project id; when supplied, scopes the request via `projectId` query param. */ + projectId?: string; } function withRangeQuery(endpoint: string, query: string): string { @@ -54,6 +56,7 @@ export function useAnalyticsArea( const query = rangeQuery(range); const invalid = isInvalidRange(range); + const { projectId } = options; const load = useCallback(async () => { if (invalid) { @@ -67,7 +70,7 @@ export function useAnalyticsArea( } setError(null); try { - const result = await api(withRangeQuery(endpoint, query)); + const result = await api(withProjectId(withRangeQuery(endpoint, query), projectId)); dataRef.current = result; setData(result); } catch (loadError: unknown) { @@ -75,7 +78,7 @@ export function useAnalyticsArea( } finally { setIsLoading(false); } - }, [endpoint, query, invalid]); + }, [endpoint, query, invalid, projectId]); useEffect(() => { void load();