diff --git a/apps/web/src/insights-stats.test.ts b/apps/web/src/insights-stats.test.ts new file mode 100644 index 000000000..8af683269 --- /dev/null +++ b/apps/web/src/insights-stats.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test"; + +import type { WorkflowRun } from "./api"; +import { computeInsightsStats } from "./insights-stats"; +import type { Routine } from "./routines-api"; + +function run( + partial: Partial & Pick, +): WorkflowRun { + return { + tenantId: "t1", + tenantName: "Bench", + definitionId: "def", + definitionName: partial.definitionName ?? "research-brief", + address: "addr", + createdAt: partial.createdAt ?? "2026-01-02T00:00:00.000Z", + ...partial, + }; +} + +function routine( + partial: Partial & Pick, +): Routine { + return { + name: "Daily dig", + definitionId: "def", + trigger: { kind: "interval", unit: "hours", every: 24 }, + scope: "bench", + input: {}, + deliveryChannelId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...partial, + }; +} + +describe("computeInsightsStats", () => { + test("counts purposeful runs by status and drops channel hosts", () => { + const stats = computeInsightsStats( + [ + run({ + id: "1", + status: "running", + createdAt: "2026-01-03T00:00:00.000Z", + }), + run({ + id: "2", + status: "error", + createdAt: "2026-01-02T00:00:00.000Z", + }), + run({ + id: "3", + status: "stopped", + createdAt: "2026-01-01T00:00:00.000Z", + }), + run({ + id: "host", + status: "running", + definitionName: "ins-cd03d8e3", + createdAt: "2026-01-04T00:00:00.000Z", + }), + ], + [ + routine({ id: "r1", enabled: true }), + routine({ id: "r2", enabled: false }), + ], + ); + + expect(stats.totalRuns).toBe(3); + expect(stats.running).toBe(1); + expect(stats.errored).toBe(1); + expect(stats.stopped).toBe(1); + expect(stats.routineCount).toBe(2); + expect(stats.enabledRoutines).toBe(1); + expect(stats.recentRuns.map((r) => r.id)).toEqual(["1", "2", "3"]); + }); + + test("limits recent runs", () => { + const runs = Array.from({ length: 5 }, (_, i) => + run({ + id: String(i), + status: "deployed", + createdAt: `2026-01-0${i + 1}T00:00:00.000Z`, + }), + ); + const stats = computeInsightsStats(runs, [], 2); + expect(stats.recentRuns).toHaveLength(2); + expect(stats.deployed).toBe(5); + }); +}); diff --git a/apps/web/src/insights-stats.ts b/apps/web/src/insights-stats.ts new file mode 100644 index 000000000..d25826dff --- /dev/null +++ b/apps/web/src/insights-stats.ts @@ -0,0 +1,73 @@ +// Pure Insights rollups over data the web already has (workflow runs + +// routines). No new analytics backend — I1 is an honest live surface on +// existing endpoints. + +import { isChannelHostDefinitionName } from "@corbits/chat/channel-host-naming"; + +import type { WorkflowRun } from "./api"; +import type { Routine } from "./routines-api"; + +export type InsightsStats = { + readonly totalRuns: number; + readonly running: number; + readonly errored: number; + readonly stopped: number; + readonly deployed: number; + readonly routineCount: number; + readonly enabledRoutines: number; + readonly recentRuns: readonly WorkflowRun[]; +}; + +/** Cap recent-run table rows so the page stays scannable. */ +export const INSIGHTS_RECENT_LIMIT = 12; + +/** Purpose runs only — drop channel-host anchors the same way Home does. */ +export function purposeRunsForInsights( + runs: readonly WorkflowRun[], +): readonly WorkflowRun[] { + return runs.filter((run) => !isChannelHostDefinitionName(run.definitionName)); +} + +export function computeInsightsStats( + runs: readonly WorkflowRun[], + routines: readonly Routine[], + recentLimit: number = INSIGHTS_RECENT_LIMIT, +): InsightsStats { + const purposeful = purposeRunsForInsights(runs); + let running = 0; + let errored = 0; + let stopped = 0; + let deployed = 0; + for (const run of purposeful) { + switch (run.status) { + case "running": + case "updating": + running += 1; + break; + case "error": + errored += 1; + break; + case "stopped": + stopped += 1; + break; + case "deployed": + deployed += 1; + break; + } + } + + const recentRuns = [...purposeful] + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, recentLimit); + + return { + totalRuns: purposeful.length, + running, + errored, + stopped, + deployed, + routineCount: routines.length, + enabledRoutines: routines.filter((r) => r.enabled).length, + recentRuns, + }; +} diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index 8096e778c..1c858e2e8 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -1,23 +1,221 @@ -import { PageShell, RichEmptyState } from "@corbits/react-ui"; +// Insights I1: live rollups from workflow runs + routines already on the hub. +// No new analytics backend — honest numbers, recent runs, and a deep-link into +// Routines for schedule management. + +import { + Badge, + PageShell, + RichEmptyState, + Section, + Skeleton, + StatGrid, + StatTile, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@corbits/react-ui"; import { ChartColumn } from "lucide-react"; +import type { ReactNode } from "react"; + +import { RunsSchema, useAPIQuery } from "../api"; +import type { APIQuery, RunsPage, WorkflowRun } from "../api"; +import { useBench } from "../bench-context"; +import { computeInsightsStats } from "../insights-stats"; +import { Link } from "../navigation"; +import { tenantKeys } from "../query-client"; +import { SignedOutNotice } from "../query-view"; +import { listRoutines, useTenantQuery, type Routine } from "../routines-api"; + +function tileValue(value: number | null, loading: boolean): ReactNode { + if (loading) return ; + if (value === null) return "—"; + return value; +} + +function statusTone( + status: WorkflowRun["status"], +): "success" | "danger" | "neutral" | "info" { + switch (status) { + case "running": + case "updating": + return "success"; + case "error": + return "danger"; + case "stopped": + return "neutral"; + case "deployed": + return "info"; + } +} + +function formatWhen(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function InsightsPage({ + runs, + routines, +}: { + readonly runs: APIQuery; + readonly routines: APIQuery; +}) { + if (runs.kind === "unauthenticated" || routines.kind === "unauthenticated") { + return ( + + + + ); + } + + const loading = runs.kind === "loading" || routines.kind === "loading"; + const failed = + runs.kind === "error" + ? runs.message + : routines.kind === "error" + ? routines.message + : null; + + if (failed !== null && !loading) { + return ( + + } + title="Couldn't load insights" + description={failed} + /> + + ); + } + + const stats = + runs.kind === "ready" && routines.kind === "ready" + ? computeInsightsStats(runs.data.data, routines.data) + : null; + + const empty = + stats !== null && stats.totalRuns === 0 && stats.routineCount === 0; -/** - * Honest stub for the Insights nav target. The analytics surface is a later - * wave ticket; the rail already needs the path so product navigation matches - * the accepted nav set (Home, Routines, Library, Agents, Skills, Insights). - */ -export function InsightsPage() { return ( - } - title="Insights aren't built yet" - description="Insights will show usage, run history, and an audit trail for this bench. There is no analytics surface wired yet, so this page has nothing real to chart." - /> +
+ + + + + + +
+ +
+ {loading ? ( + + ) : empty ? ( + } + title="Nothing to chart yet" + description="Start a purpose workflow or create a routine — Insights will roll them up here." + actions={[ + { + label: "Open Routines", + href: "/routines", + variant: "primary", + }, + ]} + /> + ) : ( +

+ Manage schedules and fire history on{" "} + Routines + {stats !== null && stats.stopped + stats.deployed > 0 + ? ` · ${stats.deployed} deployed · ${stats.stopped} stopped` + : null} + . +

+ )} +
+ + {stats !== null && stats.recentRuns.length > 0 ? ( +
+ + + + Workflow + Status + Bench + Started + + + + {stats.recentRuns.map((row) => ( + + {row.definitionName} + + {row.status} + + {row.tenantName} + {formatWhen(row.createdAt)} + + ))} + +
+
+ ) : null}
); } export function InsightsRoute() { - return ; + const { selectedTenantId } = useBench(); + const runs = useAPIQuery("/api/me/workflows/runs", RunsSchema); + const routines = useTenantQuery( + selectedTenantId === null + ? ["tenant", "none", "routines"] + : tenantKeys.routines(selectedTenantId), + selectedTenantId !== null, + () => listRoutines(selectedTenantId as string), + ); + + // No bench selected: still show run rollups (me-scoped); routines empty. + const routinesForPage: APIQuery = + selectedTenantId === null ? { kind: "ready", data: [] } : routines; + + return ; }