Adds a read-only per-workflow analytics surface for Command Center operators. - Add a core workflow analytics aggregator with token, cost, task status, and files-changed rollups. - Expose workflow analytics through JSON and CSV Command Center routes using the scoped store default workflow. - Add the Workflows Command Center tab, localized copy, docs, tests, and a release changeset. Files changed: .changeset/fn-7146-workflow-analytics.md | 7 + docs/dashboard-guide.md | 7 +- .../core/src/__tests__/workflow-analytics.test.ts | 306 +++++++++++++++++++ packages/core/src/index.ts | 7 + packages/core/src/workflow-analytics.ts | 339 +++++++++++++++++++++ .../components/command-center/CommandCenter.tsx | 8 + .../__tests__/CommandCenter.test.tsx | 51 +++- .../command-center/areas/WorkflowArea.tsx | 170 +++++++++++ .../areas/__tests__/WorkflowArea.test.tsx | 136 +++++++++ .../register-command-center-routes.auth.test.ts | 5 + .../register-command-center-routes.test.ts | 109 +++++++ packages/dashboard/src/command-center-csv.ts | 57 ++++ .../src/routes/register-command-center-routes.ts | 30 ++ packages/i18n/locales/en/app.json | 21 ++ packages/i18n/locales/es/app.json | 21 ++ packages/i18n/locales/fr/app.json | 21 ++ packages/i18n/locales/ko/app.json | 21 ++ packages/i18n/locales/zh-CN/app.json | 21 ++ packages/i18n/locales/zh-TW/app.json | 21 ++ 19 files changed, 1354 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-7146 Fusion-Task-Lineage: f75e40a5-9010-45d5-a729-132e547887d7 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
291 lines
8.8 KiB
TypeScript
291 lines
8.8 KiB
TypeScript
import type {
|
|
TokenAnalytics,
|
|
ToolAnalytics,
|
|
ActivityAnalytics,
|
|
ProductivityAnalytics,
|
|
GithubIssueAnalytics,
|
|
WorkflowAnalytics,
|
|
} from "@fusion/core";
|
|
|
|
/**
|
|
* Command Center CSV serialization (U8).
|
|
*
|
|
* RFC-4180 serialization of the Phase-A analytics aggregator output so any
|
|
* analytics table can be exported as `text/csv`. Pure: these helpers take an
|
|
* already-aggregated, already-project-scoped result and emit a string. The
|
|
* route handler (`register-command-center-routes.ts`) is responsible for
|
|
* resolving the project-scoped store and running the aggregator first, exactly
|
|
* like the JSON path — there is no DB access here, so there is no scoping leak
|
|
* surface in this module.
|
|
*
|
|
* Format guarantees (RFC-4180):
|
|
* - A header row is always emitted, even for an empty result (header-only CSV,
|
|
* never a 204 / empty body).
|
|
* - Fields containing a comma, double-quote, CR, or LF are wrapped in double
|
|
* quotes; embedded double-quotes are doubled.
|
|
* - Records are terminated with CRLF (`\r\n`), consistently.
|
|
*/
|
|
|
|
const CRLF = "\r\n";
|
|
|
|
/** A scalar cell value. `null`/`undefined` serialize to an empty field. */
|
|
export type CsvCell = string | number | boolean | null | undefined;
|
|
|
|
/** A logical table: a fixed header plus zero or more rows of cells. */
|
|
export interface CsvTable {
|
|
header: readonly string[];
|
|
rows: readonly (readonly CsvCell[])[];
|
|
}
|
|
|
|
/** Quote a single field per RFC-4180 when (and only when) required. */
|
|
function quoteField(value: CsvCell): string {
|
|
if (value === null || value === undefined) return "";
|
|
const s = typeof value === "string" ? value : String(value);
|
|
if (/[",\r\n]/.test(s)) {
|
|
return `"${s.replace(/"/g, '""')}"`;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
/** Serialize one record (array of cells) to a CSV line (no terminator). */
|
|
function serializeRecord(record: readonly CsvCell[]): string {
|
|
return record.map(quoteField).join(",");
|
|
}
|
|
|
|
/**
|
|
* Serialize a {@link CsvTable} to an RFC-4180 string. Always emits the header
|
|
* row; an empty `rows` yields a header-only document. The document is
|
|
* CRLF-terminated, including a trailing CRLF after the final record (RFC-4180
|
|
* permits this and it keeps the empty/non-empty cases uniform).
|
|
*/
|
|
export function serializeCsv(table: CsvTable): string {
|
|
const lines: string[] = [serializeRecord(table.header)];
|
|
for (const row of table.rows) {
|
|
lines.push(serializeRecord(row));
|
|
}
|
|
return lines.join(CRLF) + CRLF;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Aggregator → CsvTable converters
|
|
//
|
|
// Each analytics result is a small nested object; we flatten the
|
|
// developer-meaningful fields into a tabular shape. For token analytics with a
|
|
// groupBy, each group becomes a row; otherwise the grand total is a single row.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Token analytics → CSV. One row per group, or a single total row. */
|
|
export function tokenAnalyticsToTable(result: TokenAnalytics): CsvTable {
|
|
const header = [
|
|
"key",
|
|
"inputTokens",
|
|
"outputTokens",
|
|
"cachedTokens",
|
|
"cacheWriteTokens",
|
|
"totalTokens",
|
|
"nTasks",
|
|
"costUsd",
|
|
"costUnavailable",
|
|
];
|
|
|
|
if (result.groupBy && result.groups.length > 0) {
|
|
const rows = result.groups.map((g) => [
|
|
g.key,
|
|
g.inputTokens,
|
|
g.outputTokens,
|
|
g.cachedTokens,
|
|
g.cacheWriteTokens,
|
|
g.totalTokens,
|
|
g.nTasks,
|
|
g.cost.usd,
|
|
g.cost.unavailable,
|
|
]);
|
|
return { header, rows };
|
|
}
|
|
|
|
const t = result.totals;
|
|
return {
|
|
header,
|
|
rows: [
|
|
[
|
|
"(total)",
|
|
t.inputTokens,
|
|
t.outputTokens,
|
|
t.cachedTokens,
|
|
t.cacheWriteTokens,
|
|
t.totalTokens,
|
|
t.nTasks,
|
|
result.cost.usd,
|
|
result.cost.unavailable,
|
|
],
|
|
],
|
|
};
|
|
}
|
|
|
|
/** Tool analytics → CSV. One row per category plus a summary row. */
|
|
export function toolAnalyticsToTable(result: ToolAnalytics): CsvTable {
|
|
const header = ["category", "count"];
|
|
const rows: CsvCell[][] = result.byCategory.map((c) => [c.category, c.count]);
|
|
// Always include the headline metrics so an empty byCategory is not empty.
|
|
rows.push(["(toolCalls)", result.toolCalls]);
|
|
rows.push(["(sessions)", result.sessions]);
|
|
rows.push(["(interventions)", result.interventions.total]);
|
|
rows.push(["(autonomyRatio)", result.autonomyRatio]);
|
|
rows.push(["(fullyAutonomous)", result.fullyAutonomous]);
|
|
return { header, rows };
|
|
}
|
|
|
|
/** Activity analytics → CSV. One row per day plus summary rows. */
|
|
export function activityAnalyticsToTable(result: ActivityAnalytics): CsvTable {
|
|
const header = ["day", "messages", "activeNodes", "activeAgents", "agentRuns"];
|
|
const rows: CsvCell[][] = result.daily.map((d) => [
|
|
d.day,
|
|
d.messages,
|
|
d.activeNodes,
|
|
d.activeAgents,
|
|
d.agentRuns,
|
|
]);
|
|
rows.push([
|
|
"(total)",
|
|
result.messages,
|
|
result.activeNodes,
|
|
result.activeAgents,
|
|
result.agentRuns.total,
|
|
]);
|
|
rows.push(["(sessions)", result.sessions, "", "", ""]);
|
|
rows.push(["(stickiness)", result.stickiness, "", "", ""]);
|
|
rows.push(["(agentRuns.total)", result.agentRuns.total, "", "", ""]);
|
|
rows.push(["(agentRuns.active)", result.agentRuns.active, "", "", ""]);
|
|
rows.push(["(agentRuns.completed)", result.agentRuns.completed, "", "", ""]);
|
|
rows.push(["(agentRuns.failed)", result.agentRuns.failed, "", "", ""]);
|
|
return { header, rows };
|
|
}
|
|
|
|
/** Productivity analytics → CSV. One row per language plus summary rows. */
|
|
export function productivityAnalyticsToTable(
|
|
result: ProductivityAnalytics,
|
|
): CsvTable {
|
|
const header = ["metric", "count"];
|
|
const rows: CsvCell[][] = [];
|
|
for (const lang of result.byLanguage) {
|
|
rows.push([`language:${lang.language}`, lang.count]);
|
|
}
|
|
rows.push(["modifiedFiles", result.modifiedFiles]);
|
|
rows.push(["commits", result.commits]);
|
|
rows.push(["pullRequests", result.pullRequests]);
|
|
rows.push(["loc", result.loc.value ?? ""]);
|
|
rows.push(["hoursSaved", result.hoursSaved.value ?? ""]);
|
|
rows.push(["completedTasks", result.taskDuration.completedTasks]);
|
|
rows.push(["avgDurationMs", result.taskDuration.averageMs ?? ""]);
|
|
rows.push(["medianDurationMs", result.taskDuration.medianMs ?? ""]);
|
|
rows.push(["p90DurationMs", result.taskDuration.p90Ms ?? ""]);
|
|
rows.push(["totalDurationMs", result.taskDuration.totalMs ?? ""]);
|
|
return { header, rows };
|
|
}
|
|
|
|
/** Workflow analytics → CSV. One row per workflow plus a summary row. */
|
|
export function workflowAnalyticsToTable(result: WorkflowAnalytics): CsvTable {
|
|
const header = [
|
|
"workflowId",
|
|
"workflowName",
|
|
"isBuiltin",
|
|
"inputTokens",
|
|
"outputTokens",
|
|
"cachedTokens",
|
|
"cacheWriteTokens",
|
|
"totalTokens",
|
|
"nTasks",
|
|
"costUsd",
|
|
"costUnavailable",
|
|
"tasksCompleted",
|
|
"tasksInProgress",
|
|
"tasksInReview",
|
|
"filesChanged",
|
|
];
|
|
const rows: CsvCell[][] = result.workflows.map((workflow) => [
|
|
workflow.workflowId,
|
|
workflow.workflowName,
|
|
workflow.isBuiltin,
|
|
workflow.tokens.inputTokens,
|
|
workflow.tokens.outputTokens,
|
|
workflow.tokens.cachedTokens,
|
|
workflow.tokens.cacheWriteTokens,
|
|
workflow.tokens.totalTokens,
|
|
workflow.tokens.nTasks,
|
|
workflow.cost.usd,
|
|
workflow.cost.unavailable,
|
|
workflow.tasksCompleted,
|
|
workflow.tasksInProgress,
|
|
workflow.tasksInReview,
|
|
workflow.filesChanged,
|
|
]);
|
|
rows.push([
|
|
"(total)",
|
|
"(total)",
|
|
"",
|
|
result.totals.tokens.inputTokens,
|
|
result.totals.tokens.outputTokens,
|
|
result.totals.tokens.cachedTokens,
|
|
result.totals.tokens.cacheWriteTokens,
|
|
result.totals.tokens.totalTokens,
|
|
result.totals.tokens.nTasks,
|
|
result.totals.cost.usd,
|
|
result.totals.cost.unavailable,
|
|
result.totals.tasksCompleted,
|
|
result.totals.tasksInProgress,
|
|
result.totals.tasksInReview,
|
|
result.totals.filesChanged,
|
|
]);
|
|
return { header, rows };
|
|
}
|
|
|
|
/** GitHub issue analytics → CSV. Daily, repo, resolved detail, and summary rows. */
|
|
export function githubIssueAnalyticsToTable(
|
|
result: GithubIssueAnalytics,
|
|
): CsvTable {
|
|
const header = [
|
|
"section",
|
|
"key",
|
|
"filed",
|
|
"fixed",
|
|
"net",
|
|
"taskId",
|
|
"taskTitle",
|
|
"resolvedAt",
|
|
"resolvedAtExact",
|
|
"url",
|
|
];
|
|
const rows: CsvCell[][] = result.daily.map((d) => [
|
|
"daily",
|
|
d.date,
|
|
d.filed,
|
|
d.fixed,
|
|
d.filed - d.fixed,
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
]);
|
|
for (const repo of result.byRepo) {
|
|
rows.push(["repo", repo.repo, repo.filed, repo.fixed, repo.filed - repo.fixed, "", "", "", "", ""]);
|
|
}
|
|
for (const issue of result.resolved) {
|
|
const key = issue.issueNumber === null ? issue.repo : `${issue.repo}#${issue.issueNumber}`;
|
|
rows.push([
|
|
"resolved",
|
|
key,
|
|
"",
|
|
"",
|
|
"",
|
|
issue.taskId,
|
|
issue.taskTitle,
|
|
issue.resolvedAt,
|
|
issue.resolvedAtExact,
|
|
issue.url,
|
|
]);
|
|
}
|
|
rows.push(["summary", "total", result.filed, result.fixed, result.net, "", "", "", "", ""]);
|
|
return { header, rows };
|
|
}
|