Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions apps/web/src/insights-stats.test.ts
Original file line number Diff line number Diff line change
@@ -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<WorkflowRun> & Pick<WorkflowRun, "id" | "status">,
): 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<Routine> & Pick<Routine, "id" | "enabled">,
): 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);
});
});
73 changes: 73 additions & 0 deletions apps/web/src/insights-stats.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
224 changes: 211 additions & 13 deletions apps/web/src/pages/insights-page.tsx
Original file line number Diff line number Diff line change
@@ -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 <Skeleton className="stat-skeleton" />;
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<RunsPage>;
readonly routines: APIQuery<readonly Routine[]>;
}) {
if (runs.kind === "unauthenticated" || routines.kind === "unauthenticated") {
return (
<PageShell width="full" className="page-fill">
<SignedOutNotice />
</PageShell>
);
}

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 (
<PageShell width="full" className="page-fill">
<RichEmptyState
icon={<ChartColumn />}
title="Couldn't load insights"
description={failed}
/>
</PageShell>
);
}

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 (
<PageShell width="full" className="page-fill">
<RichEmptyState
icon={<ChartColumn />}
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."
/>
<Section
title="Insights"
description="Live activity on this bench — workflow runs and routines. Deeper analytics land later."
>
<StatGrid>
<StatTile
label="Purpose runs"
value={tileValue(stats?.totalRuns ?? null, loading)}
/>
<StatTile
label="Running now"
value={tileValue(stats?.running ?? null, loading)}
/>
<StatTile
label="Errored"
value={tileValue(stats?.errored ?? null, loading)}
/>
<StatTile
label="Routines enabled"
value={tileValue(
stats === null ? null : stats.enabledRoutines,
loading,
)}
/>
</StatGrid>
</Section>

<Section
title="Routines"
description={
stats === null
? "Scheduled and on-demand workflows on this bench."
: `${stats.routineCount} total · ${stats.enabledRoutines} enabled`
}
>
{loading ? (
<Skeleton className="insights-routines-skeleton" />
) : empty ? (
<RichEmptyState
icon={<ChartColumn />}
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",
},
]}
/>
) : (
<p className="panel-muted">
Manage schedules and fire history on{" "}
<Link to="/routines">Routines</Link>
{stats !== null && stats.stopped + stats.deployed > 0
? ` · ${stats.deployed} deployed · ${stats.stopped} stopped`
: null}
.
</p>
)}
</Section>

{stats !== null && stats.recentRuns.length > 0 ? (
<Section
title="Recent runs"
description="Newest purpose workflow runs first (channel hosts hidden)."
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Workflow</TableHead>
<TableHead>Status</TableHead>
<TableHead>Bench</TableHead>
<TableHead>Started</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.recentRuns.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.definitionName}</TableCell>
<TableCell>
<Badge tone={statusTone(row.status)}>{row.status}</Badge>
</TableCell>
<TableCell>{row.tenantName}</TableCell>
<TableCell>{formatWhen(row.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
) : null}
</PageShell>
);
}

export function InsightsRoute() {
return <InsightsPage />;
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<readonly Routine[]> =
selectedTenantId === null ? { kind: "ready", data: [] } : routines;

return <InsightsPage runs={runs} routines={routinesForPage} />;
}
Loading