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