FN-6651: fix Command Center in-progress count
Command Center now shows current board-state in-progress tasks instead of historical funnel entries. - Fetch the live Command Center snapshot for Overview live activity metrics. - Source tasks-in-progress from current live column counts with loading and failure fallbacks. - Cover populated, zero, missing-column, pending, failure, range-independent, and mobile overview cases. - Document that the live snapshot count is independent of analytics date ranges. Files changed: docs/dashboard-guide.md | 2 +- .../components/command-center/CommandCenter.tsx | 36 +++++++- .../__tests__/CommandCenter.mobile-scroll.test.tsx | 12 +++ .../__tests__/CommandCenter.test.tsx | 98 +++++++++++++++++++++- 4 files changed, 142 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-6651 Fusion-Task-Lineage: a4c01d6f-437f-49e0-baf9-fb6b76d5e8e8
This commit is contained in:
@@ -663,7 +663,7 @@ Navigation:
|
||||
|
||||
Features:
|
||||
- Global date-range picker in the header scopes the analytics tabs; **Mission Control** remains live rather than historical.
|
||||
- **Overview** summarizes token usage/cost, autonomy, active nodes, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. It also shows a graph-rich software-factory snapshot with tokens-by-model, tool-category, and daily activity trend charts that reuse the already-loaded tokens, tools, and activity analytics; no extra endpoint is called. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range.
|
||||
- **Overview** summarizes token usage/cost, autonomy, active nodes, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. Its Live activity snapshot shows the current board-state count for tasks in progress, independent of the selected analytics date range. It also shows a graph-rich software-factory snapshot with tokens-by-model, tool-category, and daily activity trend charts that reuse the already-loaded tokens, tools, and activity analytics. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range.
|
||||
- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage.
|
||||
- **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories.
|
||||
- **Activity** tracks sessions, messages, active nodes, active agents, and stickiness, then renders live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`). These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Gauge } from "lucide-react";
|
||||
import type { ActivityAnalytics, TokenAnalytics, ToolAnalytics } from "@fusion/core";
|
||||
import type { ActivityAnalytics, LiveSnapshot, TokenAnalytics, ToolAnalytics } from "@fusion/core";
|
||||
import { api } from "../../api/legacy";
|
||||
import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker";
|
||||
import { TokensArea } from "./areas/TokensArea";
|
||||
@@ -66,6 +66,8 @@ function OverviewTab({ range }: { range: DateRange }) {
|
||||
const activity = useAnalyticsArea<ActivityAnalytics>("/command-center/activity", range);
|
||||
const [signals, setSignals] = useState<SignalsAnalytics | null>(null);
|
||||
const [signalsLoading, setSignalsLoading] = useState(true);
|
||||
const [liveSnapshot, setLiveSnapshot] = useState<LiveSnapshot | null>(null);
|
||||
const [liveSnapshotLoading, setLiveSnapshotLoading] = useState(true);
|
||||
|
||||
const signalsQuery = rangeQuery(range);
|
||||
const invalidRange = isInvalidRange(range);
|
||||
@@ -99,12 +101,40 @@ function OverviewTab({ range }: { range: DateRange }) {
|
||||
};
|
||||
}, [signalsQuery, invalidRange]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLiveSnapshotLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await api<LiveSnapshot>("/command-center/live");
|
||||
if (!cancelled) {
|
||||
setLiveSnapshot(result);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLiveSnapshot(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLiveSnapshotLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tokenTotal = tokens.data?.totals?.totalTokens ?? 0;
|
||||
const toolCalls = tools.data?.toolCalls ?? 0;
|
||||
const activeNodes = activity.data?.activeNodes ?? 0;
|
||||
const activeAgents = activity.data?.activeAgents ?? 0;
|
||||
const tasksDone = activity.data?.funnel?.doneInRange ?? 0;
|
||||
const inProgressTasks = activity.data?.funnel?.stages.find((stage) => stage.stage === "in-progress")?.entered ?? 0;
|
||||
/*
|
||||
FNXC:CommandCenter 2026-06-18-00:00:
|
||||
The Live activity snapshot "tasks in progress" metric must reflect current board state from /command-center/live columns, not the date-range SDLC funnel entered count, because cumulative transitions inflate with history and do not decrease when tasks leave in-progress.
|
||||
*/
|
||||
const inProgressTasks = liveSnapshot?.columns.find((column) => column.column === "in-progress")?.count ?? 0;
|
||||
const uniqueModels = tokens.data?.groups?.length ?? 0;
|
||||
const tokensByModelData = useMemo<BarDatum[]>(
|
||||
() =>
|
||||
@@ -246,7 +276,7 @@ function OverviewTab({ range }: { range: DateRange }) {
|
||||
</div>
|
||||
<div className="cc-live-strip-metrics" data-testid="command-center-live-snapshot">
|
||||
<span className="cc-live-metric" data-testid="command-center-live-tasks-in-progress">
|
||||
<span className="cc-live-metric-value">{formatCount(inProgressTasks)}</span>
|
||||
<span className="cc-live-metric-value">{liveSnapshotLoading ? "—" : formatCount(inProgressTasks)}</span>
|
||||
<span className="cc-live-metric-label">{t("commandCenter.overview.tasksInProgress", "tasks in progress")}</span>
|
||||
</span>
|
||||
<span className="cc-live-metric" data-testid="command-center-live-agents-working">
|
||||
|
||||
@@ -114,6 +114,17 @@ function mockOverviewApi({ populated = false }: { populated?: boolean } = {}) {
|
||||
if (path.startsWith("/command-center/tools")) return Promise.resolve(populated ? populatedToolsFixture() : emptyToolsFixture());
|
||||
if (path.startsWith("/command-center/activity")) return Promise.resolve(populated ? populatedActivityFixture() : emptyActivityFixture());
|
||||
if (path.startsWith("/command-center/signals")) return Promise.resolve({ totalSignals: 0, open: 0, resolved: 0, mttr: { value: null, unavailable: true }, bySource: [], bySeverity: [] });
|
||||
if (path === "/command-center/live") {
|
||||
return Promise.resolve({
|
||||
capturedAt: "2026-06-18T00:00:00.000Z",
|
||||
activeSessions: 0,
|
||||
activeRuns: 0,
|
||||
activeNodes: 0,
|
||||
sessions: [],
|
||||
runs: [],
|
||||
columns: [{ column: "in-progress", count: populated ? 1 : 0 }],
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`Unhandled api path: ${path}`));
|
||||
});
|
||||
}
|
||||
@@ -189,6 +200,7 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
|
||||
|
||||
await screen.findByTestId("command-center-overview-charts");
|
||||
expect(screen.getByTestId("command-center-overview-chart-tokens")).toBeTruthy();
|
||||
expect(screen.getByTestId("command-center-live-tasks-in-progress")).toBeTruthy();
|
||||
assertScrollOwnerContract(screen.getByTestId("command-center-panel-overview"));
|
||||
});
|
||||
|
||||
|
||||
@@ -113,16 +113,30 @@ function signalsFixture(open = 2) {
|
||||
};
|
||||
}
|
||||
|
||||
function liveFixture(columns: Array<{ column: string; count: number }> = [{ column: "in-progress", count: 3 }]) {
|
||||
return {
|
||||
capturedAt: "2026-06-18T00:00:00.000Z",
|
||||
activeSessions: 0,
|
||||
activeRuns: 0,
|
||||
activeNodes: 0,
|
||||
sessions: [],
|
||||
runs: [],
|
||||
columns,
|
||||
};
|
||||
}
|
||||
|
||||
function mockOverviewApi({
|
||||
tokens = tokenFixture(),
|
||||
tools = toolsFixture(),
|
||||
activity = activityFixture(),
|
||||
signals = signalsFixture(),
|
||||
live = liveFixture(),
|
||||
}: {
|
||||
tokens?: unknown;
|
||||
tools?: unknown;
|
||||
activity?: unknown;
|
||||
signals?: unknown;
|
||||
live?: unknown;
|
||||
} = {}) {
|
||||
apiMock.mockImplementation((path: string) => {
|
||||
if (path.startsWith("/command-center/tokens")) return Promise.resolve(tokens);
|
||||
@@ -131,12 +145,15 @@ function mockOverviewApi({
|
||||
if (path.startsWith("/command-center/signals")) {
|
||||
return signals instanceof Error ? Promise.reject(signals) : Promise.resolve(signals);
|
||||
}
|
||||
if (path === "/command-center/live") {
|
||||
return live instanceof Error ? Promise.reject(live) : Promise.resolve(live);
|
||||
}
|
||||
return Promise.reject(new Error(`Unhandled api path: ${path}`));
|
||||
});
|
||||
}
|
||||
|
||||
function mockEmptyOverviewApi() {
|
||||
mockOverviewApi({ tokens: tokenFixture(0), tools: toolsFixture(0), activity: emptyActivityFixture(), signals: signalsFixture(0) });
|
||||
mockOverviewApi({ tokens: tokenFixture(0), tools: toolsFixture(0), activity: emptyActivityFixture(), signals: signalsFixture(0), live: liveFixture([{ column: "in-progress", count: 0 }]) });
|
||||
}
|
||||
|
||||
function statValue(testId: string) {
|
||||
@@ -145,6 +162,10 @@ function statValue(testId: string) {
|
||||
).textContent;
|
||||
}
|
||||
|
||||
function liveMetricValue(testId = "command-center-live-tasks-in-progress") {
|
||||
return screen.getByTestId(testId).querySelector(".cc-live-metric-value")?.textContent ?? null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset();
|
||||
mockEmptyOverviewApi();
|
||||
@@ -184,7 +205,7 @@ describe("CommandCenter shell", () => {
|
||||
expect(statValue("command-center-stat-signals")).toBe("2");
|
||||
expect(screen.getByTestId("command-center-live-strip")).toBeTruthy();
|
||||
expect(screen.getByTestId("command-center-live-snapshot")).toBeTruthy();
|
||||
expect(screen.getByTestId("command-center-live-tasks-in-progress").textContent).toContain("3");
|
||||
await waitFor(() => expect(liveMetricValue()).toBe("3"));
|
||||
expect(screen.getByTestId("command-center-live-agents-working").textContent).toContain("2");
|
||||
expect(screen.getByTestId("command-center-live-open-signals").textContent).toContain("2");
|
||||
expect(screen.getByTestId("command-center-throughput-trend")).toBeTruthy();
|
||||
@@ -198,6 +219,77 @@ describe("CommandCenter shell", () => {
|
||||
expect(screen.getByRole("img", { name: "Daily activity trend" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sources live tasks in progress from current column counts instead of funnel entered", async () => {
|
||||
mockOverviewApi({
|
||||
activity: activityFixture({ inProgress: 12 }),
|
||||
live: liveFixture([{ column: "in-progress", count: 2 }]),
|
||||
});
|
||||
render(<CommandCenter />);
|
||||
|
||||
await screen.findByTestId("command-center-live-tasks-in-progress");
|
||||
await waitFor(() => expect(liveMetricValue()).toBe("2"));
|
||||
expect(liveMetricValue()).not.toBe("12");
|
||||
});
|
||||
|
||||
it("renders zero when the live in-progress column count is zero", async () => {
|
||||
mockOverviewApi({ live: liveFixture([{ column: "in-progress", count: 0 }]) });
|
||||
render(<CommandCenter />);
|
||||
|
||||
await screen.findByTestId("command-center-live-tasks-in-progress");
|
||||
await waitFor(() => expect(liveMetricValue()).toBe("0"));
|
||||
});
|
||||
|
||||
it("defaults to zero when the live snapshot omits the in-progress column", async () => {
|
||||
mockOverviewApi({ live: liveFixture([{ column: "todo", count: 5 }]) });
|
||||
render(<CommandCenter />);
|
||||
|
||||
await screen.findByTestId("command-center-live-tasks-in-progress");
|
||||
await waitFor(() => expect(liveMetricValue()).toBe("0"));
|
||||
});
|
||||
|
||||
it("renders a deterministic placeholder while the live snapshot is pending", async () => {
|
||||
const live = new Promise(() => undefined);
|
||||
mockOverviewApi({ live });
|
||||
render(<CommandCenter />);
|
||||
|
||||
await screen.findByTestId("command-center-live-tasks-in-progress");
|
||||
expect(liveMetricValue()).toBe("—");
|
||||
});
|
||||
|
||||
it("falls back without crashing when the live snapshot fetch fails", async () => {
|
||||
mockOverviewApi({ live: new Error("live failed") });
|
||||
render(<CommandCenter />);
|
||||
|
||||
await screen.findByTestId("command-center-live-tasks-in-progress");
|
||||
await waitFor(() => expect(liveMetricValue()).toBe("0"));
|
||||
expect(screen.queryByTestId("command-center-overview-error")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the live in-progress count range-independent while tasks done follows the range", async () => {
|
||||
apiMock.mockImplementation((path: string) => {
|
||||
const allTime = typeof path === "string" && !path.includes("from=");
|
||||
if (path.startsWith("/command-center/tokens")) return Promise.resolve(tokenFixture());
|
||||
if (path.startsWith("/command-center/tools")) return Promise.resolve(toolsFixture());
|
||||
if (path.startsWith("/command-center/activity")) {
|
||||
return Promise.resolve(activityFixture({ doneInRange: allTime ? 21 : 7, inProgress: allTime ? 99 : 12 }));
|
||||
}
|
||||
if (path.startsWith("/command-center/signals")) return Promise.resolve(signalsFixture(2));
|
||||
if (path === "/command-center/live") return Promise.resolve(liveFixture([{ column: "in-progress", count: 4 }]));
|
||||
return Promise.reject(new Error(`Unhandled api path: ${path}`));
|
||||
});
|
||||
render(<CommandCenter />);
|
||||
|
||||
await screen.findByTestId("command-center-stat-tasksDone");
|
||||
await waitFor(() => expect(liveMetricValue()).toBe("4"));
|
||||
expect(statValue("command-center-stat-tasksDone")).toBe("7");
|
||||
|
||||
fireEvent.click(screen.getByTestId("cc-date-range-trigger"));
|
||||
fireEvent.click(screen.getByTestId("cc-date-range-preset-all"));
|
||||
|
||||
await waitFor(() => expect(statValue("command-center-stat-tasksDone")).toBe("21"));
|
||||
expect(liveMetricValue()).toBe("4");
|
||||
});
|
||||
|
||||
it("renders cards for partially populated analytics instead of the empty state", async () => {
|
||||
mockOverviewApi({ tokens: tokenFixture(0), tools: toolsFixture(0), activity: activityFixture({ sessions: 0, messages: 0, activeNodes: 1, activeAgents: 0, doneInRange: 0 }), signals: signalsFixture(0) });
|
||||
render(<CommandCenter />);
|
||||
@@ -273,6 +365,7 @@ describe("CommandCenter shell", () => {
|
||||
if (path.startsWith("/command-center/tools")) return Promise.resolve(toolsFixture(0));
|
||||
if (path.startsWith("/command-center/activity")) return Promise.resolve(emptyActivityFixture());
|
||||
if (path.startsWith("/command-center/signals")) return Promise.resolve(signalsFixture(0));
|
||||
if (path === "/command-center/live") return Promise.resolve(liveFixture([{ column: "in-progress", count: 0 }]));
|
||||
return Promise.reject(new Error(`Unhandled api path: ${path}`));
|
||||
});
|
||||
render(<CommandCenter />);
|
||||
@@ -291,6 +384,7 @@ describe("CommandCenter shell", () => {
|
||||
if (path.startsWith("/command-center/tools")) return Promise.resolve(populated ? toolsFixture() : toolsFixture(0));
|
||||
if (path.startsWith("/command-center/activity")) return Promise.resolve(populated ? activityFixture() : emptyActivityFixture());
|
||||
if (path.startsWith("/command-center/signals")) return Promise.resolve(populated ? signalsFixture() : signalsFixture(0));
|
||||
if (path === "/command-center/live") return Promise.resolve(liveFixture([{ column: "in-progress", count: populated ? 3 : 0 }]));
|
||||
return Promise.reject(new Error(`Unhandled api path: ${path}`));
|
||||
});
|
||||
render(<CommandCenter />);
|
||||
|
||||
Reference in New Issue
Block a user