fix(dashboard): project-scope Command Center analytics (FUX-037)

Apply FUX-037 projectId scoping to Command Center and Reliability view.
This commit is contained in:
ddonaldson130
2026-07-09 03:16:55 -04:00
committed by gsxdsm
parent f10c39fa0b
commit 8dce51fdd9
27 changed files with 403 additions and 157 deletions

View File

@@ -6254,7 +6254,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)}`;

View File

@@ -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<ReliabilityResponse | null>(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<ReliabilityResponse>(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<void>(withProjectId("/health/reliability/reset", projectId), { method: "POST" });
await load();
setShowResetConfirm(false);
}, [load]);
}, [load, projectId]);
useEffect(() => {
void load();

View File

@@ -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(
<div
data-testid="project-content"
style={{ display: "flex", flex: "1 1 auto", height: "100%", minHeight: 0, minWidth: 0, width: "100%", overflow: "hidden" }}
>
<ReliabilityView />
<ReliabilityView projectId={projectId} />
</div>,
);
}
@@ -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<Response>(() => {}));
apiMock.mockReturnValue(new Promise(() => {}));
render(<ReliabilityView />);
@@ -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(<ReliabilityView />);
@@ -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(<ReliabilityView />);
await waitFor(() => expect(apiMock).toHaveBeenCalled());
expect(apiMock.mock.calls[0]?.[0]).toBe("/health/reliability");
unmount();
apiMock.mockClear();
apiMock.mockResolvedValue(baseResponse);
render(<ReliabilityView projectId="proj-xyz" />);
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<Response>(() => {}));
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<Response>(() => {}));
apiMock
.mockResolvedValueOnce(baseResponse)
.mockReturnValueOnce(new Promise(() => {}));
render(<ReliabilityView />);
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(<ReliabilityView />);
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(<ReliabilityView />);
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(<ReliabilityView />);
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(<ReliabilityView />);
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(<ReliabilityView />);
@@ -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(<ReliabilityView />);
@@ -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(<ReliabilityView />);
render(<ReliabilityView projectId="proj-reset" />);
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(<ReliabilityView />);
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(<ReliabilityView />);
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(<ReliabilityView />);
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(<ReliabilityView />);
await waitFor(() => expect(screen.getByText("Insufficient data — no-in-review-entries")).toBeInTheDocument());

View File

@@ -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<TokenAnalytics>("/command-center/tokens?groupBy=model", range, {
pollMs: OVERVIEW_TOKEN_REFRESH_MS,
projectId,
});
const tools = useAnalyticsArea<ToolAnalytics>("/command-center/tools", range);
const activity = useAnalyticsArea<ActivityAnalytics>("/command-center/activity", range);
const signals = useAnalyticsArea<SignalsAnalytics>("/command-center/signals", range);
const tools = useAnalyticsArea<ToolAnalytics>("/command-center/tools", range, { projectId });
const activity = useAnalyticsArea<ActivityAnalytics>("/command-center/activity", range, { projectId });
const signals = useAnalyticsArea<SignalsAnalytics>("/command-center/signals", range, { projectId });
const [liveSnapshot, setLiveSnapshot] = useState<LiveSnapshot | null>(null);
const [liveSnapshotLoading, setLiveSnapshotLoading] = useState(true);
@@ -161,7 +162,7 @@ function OverviewTab({
setLiveSnapshotLoading(true);
void (async () => {
try {
const result = await api<LiveSnapshot>("/command-center/live");
const result = await api<LiveSnapshot>(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 = (
<div className="cc-overview-throughput" data-testid="command-center-throughput">
<SdlcFunnel range={range} />
<SdlcFunnel range={range} projectId={projectId} />
</div>
);
@@ -562,33 +563,33 @@ export function CommandCenter({
/>
);
case "tokens":
return <TokensArea range={range} />;
return <TokensArea range={range} projectId={projectId} />;
case "tools":
return <ToolsArea range={range} />;
return <ToolsArea range={range} projectId={projectId} />;
case "activity":
return <ActivityArea range={range} />;
return <ActivityArea range={range} projectId={projectId} />;
case "productivity":
return <ProductivityArea range={range} />;
return <ProductivityArea range={range} projectId={projectId} />;
case "team":
return <TeamArea range={range} projectId={projectId} addToast={addToast} />;
case "workflows":
return <WorkflowArea range={range} />;
return <WorkflowArea range={range} projectId={projectId} />;
case "ecosystem":
return <EcosystemArea range={range} />;
return <EcosystemArea range={range} projectId={projectId} />;
case "github":
return <GithubArea range={range} />;
return <GithubArea range={range} projectId={projectId} />;
case "gitlab":
return <GitlabArea range={range} />;
return <GitlabArea range={range} projectId={projectId} />;
case "signals":
return <SignalsArea range={range} />;
return <SignalsArea range={range} projectId={projectId} />;
case "system":
return <SystemStatsArea />;
case "nodes":
return <NodesView addToast={addToast} />;
case "reliability":
return <ReliabilityView />;
return <ReliabilityView projectId={projectId} />;
case "mission-control":
return <MissionControlPanel />;
return <MissionControlPanel projectId={projectId} />;
default:
return <PlaceholderTab tabId={activeTab} />;
}

View File

@@ -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<LiveSnapshot | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -106,7 +106,7 @@ export function useLiveSnapshot(): LiveSnapshotState {
if (inFlightRef.current) return;
inFlightRef.current = true;
try {
const result = await api<LiveSnapshot>("/command-center/live");
const result = await api<LiveSnapshot>(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(

View File

@@ -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<ActivityAnalytics>(
"/command-center/activity",
range,
{ projectId },
);
const funnel: SdlcFunnelData | null = data?.funnel ?? null;

View File

@@ -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 }),

View File

@@ -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.

View File

@@ -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);
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}`));
});
try {
render(<CommandCenter />);
expect(screen.getAllByTestId("command-center-tab-reliability")).toHaveLength(1);
render(<CommandCenter projectId="project-a" />);
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();
}
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 () => {

View File

@@ -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(<MissionControlPanel />);
await flush();
expect(apiMock.mock.calls.at(-1)?.[0]).toBe("/command-center/live");
unmount();
apiMock.mockClear();
apiMock.mockResolvedValue(snapshot());
render(<MissionControlPanel projectId="proj-abc" />);
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");

View File

@@ -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(<SdlcFunnel range={range7d} projectId="proj-funnel" />);
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(<SdlcFunnel range={range7d} />);
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(<SdlcFunnel range={range7d} />);

View File

@@ -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<ActivityAnalytics>("/command-center/activity", range);
const { data, isLoading, error, reload } = useAnalyticsArea<ActivityAnalytics>("/command-center/activity", range, {
projectId,
});
const daily = useMemo(() => data?.daily ?? [], [data?.daily]);
const messagesSeries = useMemo(() => daily.map((d) => d.messages), [daily]);

View File

@@ -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<TokenAnalytics>(
"/command-center/tokens?groupBy=model&granularity=day",
range,
{ projectId },
);
const { data: pluginActivations, isLoading: pluginActivationsLoading } = useAnalyticsArea<PluginActivationAnalytics>(
"/command-center/plugin-activations",
range,
{ projectId },
);
const models = useMemo(

View File

@@ -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<GithubIssueAnalytics>(
"/command-center/github",
range,
{ projectId },
);
const [isBackfilling, setIsBackfilling] = useState(false);
const [backfillResult, setBackfillResult] = useState<BackfillAggregate | null>(null);

View File

@@ -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<GitlabIssueAnalytics>(
"/command-center/gitlab",
range,
{ projectId },
);
const daily = useMemo(() => data?.daily ?? [], [data?.daily]);

View File

@@ -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<ProductivityAnalytics>(
"/command-center/productivity",
range,
{ projectId },
);
const [isBackfilling, setIsBackfilling] = useState(false);
const [backfillReport, setBackfillReport] = useState<CommitAssociationDiffBackfillReport | null>(null);

View File

@@ -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<SignalsAnalyticsWithConnectors>("/command-center/signals", range);
const { data, isLoading } = useAnalyticsArea<SignalsAnalyticsWithConnectors>("/command-center/signals", range, {
projectId,
});
const { data: connectorsData, isLoading: isConnectorsLoading } = useAnalyticsArea<SignalConnectorsResponse>(
"/command-center/signals/connectors",
range,
{ projectId },
);
const sourceBars = useMemo(

View File

@@ -207,6 +207,7 @@ export function TeamArea({
const [orgChartViewportWidth, setOrgChartViewportWidth] = useState(0);
const { data, isLoading, error } = useAnalyticsArea<TeamAnalytics>("/command-center/team", range, {
pollMs: TEAM_LIVE_REFRESH_MS,
projectId,
});
useEffect(() => {

View File

@@ -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<TokenTimeGranularity>("day");
const endpoint = `/command-center/tokens?groupBy=model&granularity=${granularity}`;
const { data, isLoading, error } = useAnalyticsArea<TokenAnalytics>(endpoint, range, {
pollMs: TOKENS_LIVE_REFRESH_MS,
projectId,
});
const groups = useMemo(() => data?.groups ?? [], [data?.groups]);

View File

@@ -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<ToolAnalytics>("/command-center/tools", range);
const { data, isLoading, error } = useAnalyticsArea<ToolAnalytics>("/command-center/tools", range, { projectId });
const sortedCategories = useMemo(
() => [...(data?.byCategory ?? [])].sort((a, b) => b.count - a.count || a.category.localeCompare(b.category)),

View File

@@ -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<WorkflowAnalytics>("/command-center/workflows", range);
const { data, isLoading, error } = useAnalyticsArea<WorkflowAnalytics>("/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.");

View File

@@ -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(<TokensArea range={range7d} projectId="proj-tokens" />);
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",
);
});
});

View File

@@ -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(<WorkflowArea range={range7d} projectId="proj-wf" />);
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(<WorkflowArea range={range7d} />);
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; }));

View File

@@ -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(<GithubArea range={range7d} projectId="proj-gh" />);
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(<GithubArea range={range7d} />);
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(<GithubArea range={range7d} />);
@@ -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(<SignalsArea range={range7d} projectId="proj-sig" />);
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(<SignalsArea range={range7d} />);

View File

@@ -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(<GitlabArea range={range7d} projectId="proj-gl" />);
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(<GitlabArea range={range7d} />);
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());

View File

@@ -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(<TokensArea range={range7d} projectId="proj-1" />);
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(<TokensArea range={range7d} />);
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(<ToolsArea range={range7d} projectId="proj-2" />);
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(<ActivityArea range={range7d} projectId="proj-3" />);
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(<ProductivityArea range={range7d} projectId="proj-4" />);
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(<EcosystemArea range={range7d} projectId="proj-5" />);
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(
<ConfirmDialogProvider>
<TeamArea range={range7d} projectId="proj-6" />
</ConfirmDialogProvider>,
);
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(<TokensArea range={range7d} projectId="proj-a" />);
await screen.findByTestId("cc-area-tokens");
expect(screen.getByTestId("cc-area-tokens").textContent).toContain("1");
rerender(<TokensArea range={range7d} projectId="proj-b" />);
await waitFor(() => expect(apiMock.mock.calls.some(([path]) => typeof path === "string" && path.includes("projectId=proj-b"))).toBe(true));
});
});

View File

@@ -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<T> {
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<T>(
const query = rangeQuery(range);
const invalid = isInvalidRange(range);
const { projectId } = options;
const load = useCallback(async () => {
if (invalid) {
@@ -67,7 +70,7 @@ export function useAnalyticsArea<T>(
}
setError(null);
try {
const result = await api<T>(withRangeQuery(endpoint, query));
const result = await api<T>(withProjectId(withRangeQuery(endpoint, query), projectId));
dataRef.current = result;
setData(result);
} catch (loadError: unknown) {
@@ -75,7 +78,7 @@ export function useAnalyticsArea<T>(
} finally {
setIsLoading(false);
}
}, [endpoint, query, invalid]);
}, [endpoint, query, invalid, projectId]);
useEffect(() => {
void load();