FN-6698: back command center signals with incidents
Replace the Command Center signals placeholders with incidents-backed analytics. - Add core signal aggregation over the incidents table with date filtering, breakdowns, and MTTR handling. - Expose the signals analytics through the Command Center API and update dashboard areas to consume real data. - Cover empty, scoped, auth, and rendered signal states with tests and update docs/changeset. Files changed: .changeset/command-center-signals.md | 5 + docs/architecture.md | 1 + docs/dashboard-guide.md | 6 +- .../core/src/__tests__/signals-analytics.test.ts | 114 ++++++++++++ packages/core/src/index.ts | 8 + packages/core/src/signals-analytics.ts | 195 +++++++++++++++++++++ .../components/command-center/CommandCenter.tsx | 49 +----- .../command-center/areas/EcosystemArea.tsx | 9 +- .../command-center/areas/ProductivityArea.tsx | 3 + .../command-center/areas/SignalsArea.tsx | 63 +------ .../register-command-center-routes.auth.test.ts | 1 + .../register-command-center-routes.test.ts | 54 ++++++ .../src/routes/register-command-center-routes.ts | 23 +++ 13 files changed, 431 insertions(+), 100 deletions(-) Fusion-Task-Id: FN-6698 Fusion-Task-Lineage: 9ce41fb2-9a92-4863-8aa7-ae124294fe09
This commit is contained in:
5
.changeset/command-center-signals.md
Normal file
5
.changeset/command-center-signals.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add the Command Center signals analytics endpoint backed by local incidents data and document honest empty-state sentinels for signal metrics.
|
||||
@@ -855,6 +855,7 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces
|
||||
Key server capabilities:
|
||||
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
|
||||
- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values, radial gauges, and trend sparklines in the Command Center System area), task/agent aggregates, and manual vitest process termination
|
||||
- Command Center analytics APIs (`GET /api/command-center/tokens`, `/tools`, `/activity`, `/productivity`, `/team`, `/github`, `/signals`, `/live`) are project-scoped dashboard routes; `/signals` aggregates real local `incidents` rows for total/open/resolved counts, MTTR, and source/severity/status breakdowns and returns honest empty/unavailable sentinels instead of synthetic signal volume.
|
||||
- Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation
|
||||
- Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md)
|
||||
- `/api/remote/tunnel/start`, `/api/remote/tunnel/stop`, and `/api/remote/tunnel/kill-external` cover tunnel lifecycle and external funnel cleanup.
|
||||
|
||||
@@ -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, agent runs, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with the existing tokens-by-model bar, tool-category bar, daily activity sparkline, plus real recharts token-share pie and daily activity multi-series line graphs. All of these reuse the already-loaded tokens, tools, and activity analytics; Overview adds no new endpoint. 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, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with the existing tokens-by-model bar, tool-category bar, daily activity sparkline, plus real recharts token-share pie and daily activity multi-series line graphs. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. 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. Per-model and per-provider breakdowns use the task's analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. It includes the existing token-usage-over-time chart, an additive recharts multi-series line graph, and a token-share pie backed by the same grouped token analytics; use the granularity control to switch the time-series request between hourly, daily, and weekly buckets. The token total and charts poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users.
|
||||
- **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. The area keeps the existing category bar and adds a recharts category-share pie from `ToolAnalytics.byCategory`. There is intentionally no tools line chart yet because `ToolAnalytics` does not expose a per-day tool trend; the dashboard does not fabricate one or call a new endpoint.
|
||||
- **Activity** tracks sessions, messages, active nodes, active agents, agent heartbeat runs, and stickiness. Agent-run sheets show total, active, completed, and failed runs for the selected range, and the Agent runs/day sparkline trends runs by `agentRuns.startedAt`. The area keeps the existing live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`), and adds a recharts multi-series line graph for messages, active agents, and agent runs plus an agent-run outcome pie from the existing `agentRuns` split. 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.
|
||||
@@ -671,7 +671,7 @@ Features:
|
||||
- **Team** shows a per-agent analytics table plus tokens-by-agent and tasks-done-by-agent charts, and adds a real token-share pie from the same per-agent token totals. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Team has no per-day analytics series today, so it intentionally does not render a line chart or fabricate a trend. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users.
|
||||
- **Ecosystem** shows active model breadth and per-model task activity; unavailable plugin-activation metrics render as unavailable rather than zero. It reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown.
|
||||
- **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, and a by-repository bar breakdown.
|
||||
- **Signals** shows external signal totals, open/resolved counts, MTTR, and source/severity breakdowns when signal sources are connected. It adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend.
|
||||
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. It shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns; an empty incidents table renders honest zero counts with MTTR unavailable rather than fabricated signal volume. It adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. External connectors that ingest third-party signals into incidents are tracked separately in FN-6706.
|
||||
- **System** is the canonical system-telemetry destination. It reuses `GET /api/system-stats` with no new endpoint, renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed.
|
||||
- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences.
|
||||
- CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
|
||||
@@ -688,7 +688,7 @@ Data states:
|
||||
- GitHub issue analytics is local and additive: empty filed/fixed totals keep the stat cards and historical backfill button available while omitting empty chart shells; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center.
|
||||
- Team analytics renders its shared loading/error/empty states for null or zero-agent responses, omits empty chart shells for zero-value datasets, and keeps the Command Center tab panel as the mobile scroll owner.
|
||||
- System telemetry keeps the previous snapshot visible during refresh failures, renders a first-sample CPU `Sampling…` state without NaN values, shows zero-value task/agent bars for empty collections while omitting the zero-value task-distribution pie, and keeps the Command Center tab panel as the mobile scroll owner.
|
||||
- Signals is best-effort: if the Signals endpoint is absent or no signal source is connected, the Signals area falls back to its empty state, omits its status pie, and other Command Center metrics remain valid.
|
||||
- Signals is best-effort over local incidents data: if the project has no incidents, the Signals area shows its empty state, omits its status pie, and other Command Center metrics remain valid; endpoint errors surface as the shared analytics error state instead of silently swallowing a missing route.
|
||||
|
||||
## Reliability View
|
||||
|
||||
|
||||
114
packages/core/src/__tests__/signals-analytics.test.ts
Normal file
114
packages/core/src/__tests__/signals-analytics.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { Database } from "../db.js";
|
||||
import { aggregateSignalsAnalytics } from "../signals-analytics.js";
|
||||
|
||||
const RANGE = { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" };
|
||||
|
||||
let incidentSeq = 0;
|
||||
function insertIncident(
|
||||
db: Database,
|
||||
fields: {
|
||||
status: "open" | "resolved";
|
||||
openedAt: string;
|
||||
resolvedAt?: string | null;
|
||||
source?: string | null;
|
||||
severity?: string | null;
|
||||
},
|
||||
): void {
|
||||
const incidentId = `sig-${incidentSeq++}`;
|
||||
const now = "2026-03-01T00:00:00.000Z";
|
||||
db.prepare(
|
||||
`INSERT INTO incidents
|
||||
(incidentId, groupingKey, title, severity, status, source, openedAt, resolvedAt, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
incidentId,
|
||||
`group-${incidentId}`,
|
||||
`Signal ${incidentId}`,
|
||||
fields.severity ?? "error",
|
||||
fields.status,
|
||||
fields.source ?? "webhook",
|
||||
fields.openedAt,
|
||||
fields.resolvedAt ?? null,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
describe("signals-analytics", () => {
|
||||
let tmpDir: string;
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
incidentSeq = 0;
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "kb-signals-analytics-"));
|
||||
db = new Database(join(tmpDir, ".fusion"));
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("aggregates real incident signals by source, severity, status, and MTTR", () => {
|
||||
insertIncident(db, {
|
||||
status: "open",
|
||||
openedAt: "2026-03-02T10:00:00.000Z",
|
||||
source: "sentry",
|
||||
severity: "critical",
|
||||
});
|
||||
insertIncident(db, {
|
||||
status: "resolved",
|
||||
openedAt: "2026-03-03T10:00:00.000Z",
|
||||
resolvedAt: "2026-03-03T10:45:00.000Z",
|
||||
source: "pagerduty",
|
||||
severity: "warning",
|
||||
});
|
||||
insertIncident(db, {
|
||||
status: "resolved",
|
||||
openedAt: "2026-02-01T10:00:00.000Z",
|
||||
resolvedAt: "2026-02-01T10:30:00.000Z",
|
||||
source: "outside",
|
||||
severity: "info",
|
||||
});
|
||||
|
||||
const result = aggregateSignalsAnalytics(db, RANGE);
|
||||
|
||||
expect(result.totalSignals).toBe(2);
|
||||
expect(result.open).toBe(1);
|
||||
expect(result.resolved).toBe(1);
|
||||
expect(result.mttr).toEqual({ value: 45, unavailable: false, sampleCount: 1 });
|
||||
expect(result.bySource).toEqual([
|
||||
{ source: "pagerduty", count: 1 },
|
||||
{ source: "sentry", count: 1 },
|
||||
]);
|
||||
expect(result.bySeverity).toEqual([
|
||||
{ severity: "critical", count: 1 },
|
||||
{ severity: "warning", count: 1 },
|
||||
]);
|
||||
expect(result.byStatus).toEqual([
|
||||
{ status: "open", count: 1 },
|
||||
{ status: "resolved", count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps MTTR as the unavailable sentinel when no incident resolved in range", () => {
|
||||
insertIncident(db, {
|
||||
status: "open",
|
||||
openedAt: "2026-03-02T10:00:00.000Z",
|
||||
source: "webhook",
|
||||
severity: "error",
|
||||
});
|
||||
|
||||
const result = aggregateSignalsAnalytics(db, RANGE);
|
||||
|
||||
expect(result.totalSignals).toBe(1);
|
||||
expect(result.mttr).toEqual({ value: null, unavailable: true, sampleCount: 0 });
|
||||
});
|
||||
});
|
||||
@@ -590,6 +590,14 @@ export type {
|
||||
GithubIssueDailyPoint,
|
||||
GithubIssueRepoBreakdown,
|
||||
} from "./github-issue-analytics.js";
|
||||
export { aggregateSignalsAnalytics } from "./signals-analytics.js";
|
||||
export type {
|
||||
SignalsAnalytics,
|
||||
SignalsAnalyticsQuery,
|
||||
SignalsBreakdown,
|
||||
SignalsSeverityBreakdown,
|
||||
SignalsStatusBreakdown,
|
||||
} from "./signals-analytics.js";
|
||||
export { composeLiveSnapshot } from "./command-center-live.js";
|
||||
export type {
|
||||
LiveSnapshot,
|
||||
|
||||
195
packages/core/src/signals-analytics.ts
Normal file
195
packages/core/src/signals-analytics.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { Database } from "./db.js";
|
||||
import type { MttrSummary } from "./activity-analytics.js";
|
||||
|
||||
/**
|
||||
* Command Center external-signal analytics over the existing `incidents` table.
|
||||
*
|
||||
* FNXC:CommandCenter 2026-06-19-00:00:
|
||||
* The Signals tab must be backed by real project data, not a swallowed 404. Use the scoped incidents table that monitor ingestion already owns; when no incident source is connected, return honest zeros plus the MTTR unavailable sentinel instead of fabricating signal volume.
|
||||
*/
|
||||
export interface SignalsAnalyticsQuery {
|
||||
/** ISO-8601 lower bound (inclusive). */
|
||||
from?: string;
|
||||
/** ISO-8601 upper bound (inclusive). */
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface SignalsBreakdown {
|
||||
source: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SignalsSeverityBreakdown {
|
||||
severity: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SignalsStatusBreakdown {
|
||||
status: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SignalsAnalytics {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
/** Incidents opened in range. */
|
||||
totalSignals: number;
|
||||
/** Open incidents opened in range. */
|
||||
open: number;
|
||||
/** Incidents resolved in range. */
|
||||
resolved: number;
|
||||
/** Mean time to resolve for incidents resolved in range. */
|
||||
mttr: MttrSummary;
|
||||
/** Incidents opened in range, grouped by source. */
|
||||
bySource: SignalsBreakdown[];
|
||||
/** Incidents opened in range, grouped by severity. */
|
||||
bySeverity: SignalsSeverityBreakdown[];
|
||||
/** Incidents opened in range, grouped by current status. */
|
||||
byStatus: SignalsStatusBreakdown[];
|
||||
}
|
||||
|
||||
interface CountRow {
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface GroupRow {
|
||||
key: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ResolvedIncidentRow {
|
||||
openedAt: string;
|
||||
resolvedAt: string;
|
||||
}
|
||||
|
||||
function tableExists(db: Database, name: string): boolean {
|
||||
const row = db
|
||||
.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name) as CountRow;
|
||||
return row.count > 0;
|
||||
}
|
||||
|
||||
function rangeWhere(column: string, query: SignalsAnalyticsQuery): { where: string; params: string[] } {
|
||||
const clauses: string[] = [];
|
||||
const params: string[] = [];
|
||||
if (query.from !== undefined) {
|
||||
clauses.push(`${column} >= ?`);
|
||||
params.push(query.from);
|
||||
}
|
||||
if (query.to !== undefined) {
|
||||
clauses.push(`${column} <= ?`);
|
||||
params.push(query.to);
|
||||
}
|
||||
return {
|
||||
where: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function emptySignals(query: SignalsAnalyticsQuery): SignalsAnalytics {
|
||||
return {
|
||||
from: query.from ?? null,
|
||||
to: query.to ?? null,
|
||||
totalSignals: 0,
|
||||
open: 0,
|
||||
resolved: 0,
|
||||
mttr: { value: null, unavailable: true, sampleCount: 0 },
|
||||
bySource: [],
|
||||
bySeverity: [],
|
||||
byStatus: [],
|
||||
};
|
||||
}
|
||||
|
||||
function count(db: Database, sql: string, params: string[]): number {
|
||||
return (db.prepare(sql).get(...params) as CountRow).count;
|
||||
}
|
||||
|
||||
function groupByColumn(
|
||||
db: Database,
|
||||
column: "source" | "severity" | "status",
|
||||
openedWhere: string,
|
||||
params: string[],
|
||||
fallback: string,
|
||||
): Array<{ key: string; count: number }> {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT COALESCE(NULLIF(TRIM(${column}), ''), ?) AS key, COUNT(*) AS count
|
||||
FROM incidents ${openedWhere}
|
||||
GROUP BY key
|
||||
ORDER BY count DESC, key ASC`,
|
||||
)
|
||||
.all(fallback, ...params) as GroupRow[];
|
||||
return rows.map((row) => ({ key: row.key ?? fallback, count: row.count }));
|
||||
}
|
||||
|
||||
function computeMttr(db: Database, query: SignalsAnalyticsQuery): MttrSummary {
|
||||
const resolvedRange = rangeWhere("resolvedAt", query);
|
||||
const resolvedWhere = resolvedRange.where
|
||||
? `${resolvedRange.where} AND resolvedAt IS NOT NULL`
|
||||
: "WHERE resolvedAt IS NOT NULL";
|
||||
const rows = db
|
||||
.prepare(`SELECT openedAt, resolvedAt FROM incidents ${resolvedWhere}`)
|
||||
.all(...resolvedRange.params) as ResolvedIncidentRow[];
|
||||
|
||||
let totalMinutes = 0;
|
||||
let sampleCount = 0;
|
||||
for (const row of rows) {
|
||||
const opened = Date.parse(row.openedAt);
|
||||
const resolved = Date.parse(row.resolvedAt);
|
||||
if (!Number.isFinite(opened) || !Number.isFinite(resolved) || resolved < opened) continue;
|
||||
totalMinutes += (resolved - opened) / 60_000;
|
||||
sampleCount += 1;
|
||||
}
|
||||
|
||||
return sampleCount === 0
|
||||
? { value: null, unavailable: true, sampleCount: 0 }
|
||||
: { value: totalMinutes / sampleCount, unavailable: false, sampleCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the Command Center Signals surface from locally recorded incidents.
|
||||
* Missing/older schemas return an honest empty payload so the dashboard can show
|
||||
* "no source connected" without pretending that a zero came from ingestion.
|
||||
*/
|
||||
export function aggregateSignalsAnalytics(
|
||||
db: Database,
|
||||
query: SignalsAnalyticsQuery = {},
|
||||
): SignalsAnalytics {
|
||||
if (!tableExists(db, "incidents")) return emptySignals(query);
|
||||
|
||||
const openedRange = rangeWhere("openedAt", query);
|
||||
const resolvedRange = rangeWhere("resolvedAt", query);
|
||||
const resolvedWhere = resolvedRange.where
|
||||
? `${resolvedRange.where} AND resolvedAt IS NOT NULL`
|
||||
: "WHERE resolvedAt IS NOT NULL";
|
||||
const openWhere = openedRange.where
|
||||
? `${openedRange.where} AND status = 'open'`
|
||||
: "WHERE status = 'open'";
|
||||
|
||||
const totalSignals = count(
|
||||
db,
|
||||
`SELECT COUNT(*) AS count FROM incidents ${openedRange.where}`,
|
||||
openedRange.params,
|
||||
);
|
||||
const open = count(db, `SELECT COUNT(*) AS count FROM incidents ${openWhere}`, openedRange.params);
|
||||
const resolved = count(db, `SELECT COUNT(*) AS count FROM incidents ${resolvedWhere}`, resolvedRange.params);
|
||||
|
||||
const bySource = groupByColumn(db, "source", openedRange.where, openedRange.params, "(unknown)")
|
||||
.map((row) => ({ source: row.key, count: row.count }));
|
||||
const bySeverity = groupByColumn(db, "severity", openedRange.where, openedRange.params, "unknown")
|
||||
.map((row) => ({ severity: row.key, count: row.count }));
|
||||
const byStatus = groupByColumn(db, "status", openedRange.where, openedRange.params, "unknown")
|
||||
.map((row) => ({ status: row.key, count: row.count }));
|
||||
|
||||
return {
|
||||
from: query.from ?? null,
|
||||
to: query.to ?? null,
|
||||
totalSignals,
|
||||
open,
|
||||
resolved,
|
||||
mttr: computeMttr(db, query),
|
||||
bySource,
|
||||
bySeverity,
|
||||
byStatus,
|
||||
};
|
||||
}
|
||||
@@ -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, LiveSnapshot, TokenAnalytics, ToolAnalytics } from "@fusion/core";
|
||||
import type { ActivityAnalytics, LiveSnapshot, SignalsAnalytics, TokenAnalytics, ToolAnalytics } from "@fusion/core";
|
||||
import { api } from "../../api/legacy";
|
||||
import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker";
|
||||
import { TokensArea } from "./areas/TokensArea";
|
||||
@@ -20,8 +20,7 @@ import { Bar, type BarDatum } from "./charts/Bar";
|
||||
import { Sparkline } from "./charts/Sparkline";
|
||||
import { LineChart as RechartsLineChart, PieChart } from "./charts/recharts";
|
||||
import { useAnalyticsArea } from "./areas/useAnalyticsArea";
|
||||
import { formatCost, formatCount, isInvalidRange, rangeQuery } from "./areas/areaShared";
|
||||
import type { SignalsAnalytics } from "./areas/SignalsArea";
|
||||
import { formatCost, formatCount } from "./areas/areaShared";
|
||||
import "./CommandCenter.css";
|
||||
|
||||
type SubViewId =
|
||||
@@ -77,7 +76,10 @@ interface OverviewStatCard {
|
||||
|
||||
/*
|
||||
FNXC:CommandCenter 2026-06-17-00:00:
|
||||
Overview is the Command Center landing surface, so it must reflect real analytics instead of shell placeholders. Show loading while core analytics have not settled, show the empty state only after settled zero data, include the agent-runs card as a first-class activity signal, and treat Signals as best-effort because that endpoint can be absent without invalidating tokens/tools/activity metrics.
|
||||
Overview is the Command Center landing surface, so it must reflect real analytics instead of shell placeholders. Show loading while core analytics have not settled, show the empty state only after settled zero data, include the agent-runs card as a first-class activity signal, and read Signals from the project-scoped incidents-backed endpoint without fabricating a value during loading/errors.
|
||||
|
||||
FNXC:CommandCenter 2026-06-19-00:00:
|
||||
The Open signals card must be real project data, not a swallowed missing-route blank. It calls the scoped `/command-center/signals` endpoint backed by incidents and renders `—` only while unavailable/loading.
|
||||
|
||||
FNXC:CommandCenter 2026-06-18-23:45:
|
||||
FN-6683 adds real Overview pie and line charts by reusing the already-fetched tokens and activity analytics. Keep the existing overview bars, sparkline, live strip, funnel, and loading/error/empty branches intact; no new endpoint is allowed for these additive affordances.
|
||||
@@ -91,43 +93,10 @@ function OverviewTab({ range }: { range: DateRange }) {
|
||||
});
|
||||
const tools = useAnalyticsArea<ToolAnalytics>("/command-center/tools", range);
|
||||
const activity = useAnalyticsArea<ActivityAnalytics>("/command-center/activity", range);
|
||||
const [signals, setSignals] = useState<SignalsAnalytics | null>(null);
|
||||
const [signalsLoading, setSignalsLoading] = useState(true);
|
||||
const signals = useAnalyticsArea<SignalsAnalytics>("/command-center/signals", range);
|
||||
const [liveSnapshot, setLiveSnapshot] = useState<LiveSnapshot | null>(null);
|
||||
const [liveSnapshotLoading, setLiveSnapshotLoading] = useState(true);
|
||||
|
||||
const signalsQuery = rangeQuery(range);
|
||||
const invalidRange = isInvalidRange(range);
|
||||
|
||||
useEffect(() => {
|
||||
if (invalidRange) {
|
||||
setSignalsLoading(false);
|
||||
setSignals(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSignalsLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await api<SignalsAnalytics>(`/command-center/signals${signalsQuery}`);
|
||||
if (!cancelled) {
|
||||
setSignals(result);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSignals(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setSignalsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [signalsQuery, invalidRange]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLiveSnapshotLoading(true);
|
||||
@@ -244,7 +213,7 @@ function OverviewTab({ range }: { range: DateRange }) {
|
||||
{
|
||||
id: "signals",
|
||||
label: t("commandCenter.overview.openSignals", "Open signals"),
|
||||
value: signalsLoading ? "—" : signals ? formatCount(signals.open ?? 0) : "—",
|
||||
value: signals.isLoading ? "—" : signals.data ? formatCount(signals.data.open ?? 0) : "—",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -330,7 +299,7 @@ function OverviewTab({ range }: { range: DateRange }) {
|
||||
<span className="cc-live-metric-label">{t("commandCenter.overview.liveTokens", "tokens")}</span>
|
||||
</span>
|
||||
<span className="cc-live-metric" data-testid="command-center-live-open-signals">
|
||||
<span className="cc-live-metric-value">{signalsLoading ? "—" : signals ? formatCount(signals.open ?? 0) : "—"}</span>
|
||||
<span className="cc-live-metric-value">{signals.isLoading ? "—" : signals.data ? formatCount(signals.data.open ?? 0) : "—"}</span>
|
||||
<span className="cc-live-metric-label">{t("commandCenter.overview.openSignals", "open signals")}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,12 @@ import { formatCount } from "./areaShared";
|
||||
* model (per KTD/plan: "reuses the tokens endpoint grouped by model where
|
||||
* possible"). Shows the unique-active-model count and a per-model activity bar
|
||||
* (tasks per model as the activity proxy — token rows carry `nTasks`, not a
|
||||
* session count). Plugin activation count and a distinct-models/day sparkline
|
||||
* have no current endpoint, so they render their unavailable sentinel rather
|
||||
* than a misleading 0. Empty state when no models have been used.
|
||||
* session count). Plugin activation count has no current event source, so it
|
||||
* renders its unavailable sentinel rather than a misleading 0. Empty state when
|
||||
* no models have been used.
|
||||
*
|
||||
* FNXC:CommandCenter 2026-06-19-00:00:
|
||||
* FN-6705 owns real plugin activation recording. Keep the Plugin activations card at `—` until activation events are persisted and aggregated; model/task token trends are real but are not a plugin proxy.
|
||||
*/
|
||||
export function EcosystemArea({ range }: { range: DateRange }) {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
@@ -22,6 +22,9 @@ ProductivityAnalytics exposes a categorical language distribution but no per-day
|
||||
* FNXC:CommandCenter 2026-06-16-09:42:
|
||||
* Productivity area of the Command Center (PR #1683). Volume proxies (files/LOC) must read as distinct
|
||||
* from outcome counters (PRs/commits), and missing LOC must render "—", never 0, to avoid implying zero work.
|
||||
*
|
||||
* FNXC:CommandCenter 2026-06-19-00:00:
|
||||
* FN-6704 owns real commit diff-stat capture for LOC. Keep this sentinel honest until a persisted additions/deletions source exists; do not backfill with modified-file counts or any other proxy.
|
||||
*/
|
||||
export function ProductivityArea({ range }: { range: DateRange }) {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
@@ -1,69 +1,24 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../../api/legacy";
|
||||
import type { SignalsAnalytics } from "@fusion/core";
|
||||
import type { DateRange } from "../DateRangePicker";
|
||||
import { Bar } from "../charts/Bar";
|
||||
import { PieChart } from "../charts/recharts";
|
||||
import { AreaShell } from "./AreaShell";
|
||||
import { rangeQuery, formatCount, isInvalidRange } from "./areaShared";
|
||||
import { useAnalyticsArea } from "./useAnalyticsArea";
|
||||
import { formatCount } from "./areaShared";
|
||||
|
||||
/*
|
||||
FNXC:CommandCenter 2026-06-16-09:42:
|
||||
Signals area of the Command Center (PR #1683). Surfaces external-signal volume/severity (Sentry/Datadog/PagerDuty/webhook ingest from U11) so operators see incoming pressure alongside internal analytics.
|
||||
*/
|
||||
Signals area of the Command Center (PR #1683). Surfaces external-signal volume/severity from the project-scoped incidents table so operators see incoming pressure alongside internal analytics.
|
||||
|
||||
/**
|
||||
* Shape the External Signals endpoint will return once U11/U13 land. Until then
|
||||
* the endpoint does not exist, so this area degrades to its empty state — it
|
||||
* must NOT surface a crash/error for the missing endpoint.
|
||||
*/
|
||||
export interface SignalsAnalytics {
|
||||
totalSignals: number;
|
||||
open: number;
|
||||
resolved: number;
|
||||
/** Mean time to resolve, minutes; null/unavailable until U13. */
|
||||
mttr: { value: number | null; unavailable: boolean };
|
||||
bySource: Array<{ source: string; count: number }>;
|
||||
bySeverity: Array<{ severity: string; count: number }>;
|
||||
}
|
||||
FNXC:CommandCenter 2026-06-19-00:00:
|
||||
Signals now reads a real `/api/command-center/signals` route backed by incidents instead of swallowing a missing endpoint. Empty still means no incident source has recorded data, and MTTR remains `—` until at least one incident is resolved. FN-6706 owns building external Sentry/Datadog/PagerDuty/webhook connectors into that incidents table.
|
||||
*/
|
||||
|
||||
export function SignalsArea({ range }: { range: DateRange }) {
|
||||
const { t } = useTranslation("app");
|
||||
const [data, setData] = useState<SignalsAnalytics | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const query = rangeQuery(range);
|
||||
const invalid = isInvalidRange(range);
|
||||
|
||||
useEffect(() => {
|
||||
if (invalid) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await api<SignalsAnalytics>(`/command-center/signals${query}`);
|
||||
if (!cancelled) {
|
||||
setData(result);
|
||||
}
|
||||
} catch {
|
||||
// U11/U13 not wired yet (or no signals): degrade to the empty state,
|
||||
// never an error. External-signal ingestion lands in Phase C.
|
||||
if (!cancelled) {
|
||||
setData(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, invalid]);
|
||||
const { data, isLoading } = useAnalyticsArea<SignalsAnalytics>("/command-center/signals", range);
|
||||
|
||||
const sourceBars = useMemo(
|
||||
() => (data?.bySource ?? []).map((s) => ({ label: s.source, value: s.count, valueLabel: formatCount(s.count) })),
|
||||
|
||||
@@ -66,6 +66,7 @@ const ENDPOINTS = [
|
||||
"/api/command-center/productivity",
|
||||
"/api/command-center/team",
|
||||
"/api/command-center/github",
|
||||
"/api/command-center/signals",
|
||||
"/api/command-center/live",
|
||||
];
|
||||
|
||||
|
||||
@@ -83,6 +83,28 @@ function seedTeamMetrics(db: Database, opts: { agentId: string; name: string; to
|
||||
);
|
||||
}
|
||||
|
||||
function seedSignalMetrics(db: Database, opts: { prefix: string; source: string; open: number; resolved: number }): void {
|
||||
let seq = 0;
|
||||
for (let i = 0; i < opts.open; i += 1) {
|
||||
db.prepare(
|
||||
`INSERT INTO incidents
|
||||
(incidentId, groupingKey, title, severity, status, source, openedAt, resolvedAt, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, 'critical', 'open', ?, '2026-03-04T00:00:00.000Z', NULL,
|
||||
'2026-03-04T00:00:00.000Z', '2026-03-04T00:00:00.000Z')`,
|
||||
).run(`${opts.prefix}-open-${seq}`, `${opts.prefix}-group-${seq}`, `Signal ${seq}`, opts.source);
|
||||
seq += 1;
|
||||
}
|
||||
for (let i = 0; i < opts.resolved; i += 1) {
|
||||
db.prepare(
|
||||
`INSERT INTO incidents
|
||||
(incidentId, groupingKey, title, severity, status, source, openedAt, resolvedAt, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, 'warning', 'resolved', ?, '2026-03-05T00:00:00.000Z', '2026-03-05T00:30:00.000Z',
|
||||
'2026-03-05T00:00:00.000Z', '2026-03-05T00:30:00.000Z')`,
|
||||
).run(`${opts.prefix}-resolved-${seq}`, `${opts.prefix}-group-${seq}`, `Signal ${seq}`, opts.source);
|
||||
seq += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function seedGithubIssueMetrics(db: Database, opts: { prefix: string; repo: string; filed: number; fixed: number }): void {
|
||||
for (let i = 0; i < opts.filed; i += 1) {
|
||||
db.prepare(
|
||||
@@ -289,6 +311,14 @@ describe("register-command-center-routes", () => {
|
||||
expect(github.body).toMatchObject({ filed: 2, fixed: 1, net: 1 });
|
||||
expect(github.body).toHaveProperty("daily");
|
||||
expect(github.body).toHaveProperty("byRepo");
|
||||
|
||||
seedSignalMetrics(dbA, { prefix: "SIG-A", source: "sentry", open: 1, resolved: 1 });
|
||||
const signals = await request(app, "GET", `/api/command-center/signals?${range}&projectId=proj-a`);
|
||||
expect(signals.status).toBe(200);
|
||||
expect(signals.body).toMatchObject({ totalSignals: 2, open: 1, resolved: 1 });
|
||||
expect(signals.body).toHaveProperty("mttr");
|
||||
expect(signals.body).toHaveProperty("bySource");
|
||||
expect(signals.body).toHaveProperty("bySeverity");
|
||||
});
|
||||
|
||||
it("returns the live snapshot shape", async () => {
|
||||
@@ -353,6 +383,29 @@ describe("register-command-center-routes", () => {
|
||||
expect(bAgents.some((agent) => agent.agentId === "agent-a-only" || agent.agentName === "Project A Agent")).toBe(false);
|
||||
});
|
||||
|
||||
it("signals endpoint defaults invalid ranges and stays project scoped", async () => {
|
||||
seedSignalMetrics(dbA, { prefix: "SIG-A", source: "sentry", open: 1, resolved: 1 });
|
||||
seedSignalMetrics(dbB, { prefix: "SIG-B", source: "pagerduty", open: 3, resolved: 2 });
|
||||
|
||||
const invalid = await request(
|
||||
app,
|
||||
"GET",
|
||||
"/api/command-center/signals?from=bad&to=range&projectId=proj-a",
|
||||
);
|
||||
expect(invalid.status).toBe(200);
|
||||
expect(invalid.body).toHaveProperty("totalSignals");
|
||||
expect(invalid.body).toHaveProperty("mttr");
|
||||
expect(invalid.body).toHaveProperty("bySource");
|
||||
|
||||
const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z";
|
||||
const a = await request(app, "GET", `/api/command-center/signals?${range}&projectId=proj-a`);
|
||||
const b = await request(app, "GET", `/api/command-center/signals?${range}&projectId=proj-b`);
|
||||
expect(a.body).toMatchObject({ totalSignals: 2, open: 1, resolved: 1 });
|
||||
expect(b.body).toMatchObject({ totalSignals: 5, open: 3, resolved: 2 });
|
||||
expect((a.body as { bySource: Array<{ source: string }> }).bySource).toContainEqual(expect.objectContaining({ source: "sentry" }));
|
||||
expect((a.body as { bySource: Array<{ source: string }> }).bySource).not.toContainEqual(expect.objectContaining({ source: "pagerduty" }));
|
||||
});
|
||||
|
||||
it("github endpoint defaults invalid ranges and stays project scoped", async () => {
|
||||
seedGithubIssueMetrics(dbA, { prefix: "FN-A", repo: "acme/alpha", filed: 2, fixed: 1 });
|
||||
seedGithubIssueMetrics(dbB, { prefix: "FN-B", repo: "acme/beta", filed: 5, fixed: 4 });
|
||||
@@ -554,6 +607,7 @@ describe("vite /api proxy negative-lookahead (proxy verification)", () => {
|
||||
expect(PROXY_RE.test("/api/command-center/team")).toBe(true);
|
||||
expect(PROXY_RE.test("/api/command-center/live")).toBe(true);
|
||||
expect(PROXY_RE.test("/api/command-center/github")).toBe(true);
|
||||
expect(PROXY_RE.test("/api/command-center/signals")).toBe(true);
|
||||
expect(PROXY_RE.test("/api/command-center/activity?from=x&to=y")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
aggregateProductivityAnalytics,
|
||||
aggregateTeamAnalytics,
|
||||
aggregateGithubIssueAnalytics,
|
||||
aggregateSignalsAnalytics,
|
||||
composeLiveSnapshot,
|
||||
type TokenGroupBy,
|
||||
type TokenTimeGranularity,
|
||||
@@ -287,6 +288,28 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/command-center/signals
|
||||
* External Signals metrics backed by locally recorded incidents.
|
||||
*
|
||||
* FNXC:CommandCenter 2026-06-19-00:00:
|
||||
* The Signals surface must not be a phantom endpoint. Mirror sibling Command Center routes by resolving getScopedStore(req) before reading incidents, so project-A callers only see project-A signal volume and MTTR stays the honest unavailable sentinel when no incidents are resolved.
|
||||
*/
|
||||
router.get("/command-center/signals", async (req, res) => {
|
||||
try {
|
||||
const store = await getScopedStore(req);
|
||||
const range = resolveRange(req.query);
|
||||
const result = aggregateSignalsAnalytics(store.getDatabase(), {
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to aggregate signal analytics");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/command-center/live
|
||||
* Live Mission-Control snapshot (U6a): active sessions/runs/nodes + current
|
||||
|
||||
Reference in New Issue
Block a user