diff --git a/apps/web/src/bench/approvals-insights.css b/apps/web/src/bench/approvals-insights.css new file mode 100644 index 000000000..f5280da1c --- /dev/null +++ b/apps/web/src/bench/approvals-insights.css @@ -0,0 +1,32 @@ +.appr { + display: grid; + gap: 12px; +} +.appr-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 4px 12px; + font-size: 0.85rem; +} +.appr-row b { + font-variant-numeric: tabular-nums; +} +.appr-row .bi-meter { + grid-column: 1 / -1; + display: flex; + width: auto; + margin-right: 0; +} +.appr-line { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + font-size: 0.85rem; +} +.appr-line i { + display: inline-block; + width: 8px; + height: 8px; + margin-right: 6px; + border-radius: 50%; +} diff --git a/apps/web/src/bench/approvals-insights.tsx b/apps/web/src/bench/approvals-insights.tsx new file mode 100644 index 000000000..f18905941 --- /dev/null +++ b/apps/web/src/bench/approvals-insights.tsx @@ -0,0 +1,98 @@ +import { Skeleton } from "@corbits/react-ui"; +import { useQueries } from "@tanstack/react-query"; + +import { apiQueryOptions } from "../api"; +import { insightsRunApprovalsPath, RunApprovalsResponse, type InsightsRun } from "../insights-api"; +import { countApprovals, type ApprovalCounts } from "../insights-stats"; + +import "./approvals-insights.css"; + +const ROWS = [ + ["once", "Allowed once", "var(--chart-ok)"], + ["always", "Always allowed", "var(--info)"], + ["denied", "Denied", "var(--chart-fail)"], + ["waiting", "Waiting on you", "var(--action)"], +] as const; + +type ApprovalTally = + | { readonly kind: "loading" } + | { readonly kind: "error" } + | { readonly kind: "ready"; readonly counts: ApprovalCounts }; + +function useApprovalTally(tenantId: string, runs: readonly InsightsRun[]): ApprovalTally { + const results = useQueries({ + queries: runs.map((run) => + apiQueryOptions(insightsRunApprovalsPath(tenantId, run.id), RunApprovalsResponse), + ), + }); + if (results.some((r) => r.isLoading)) return { kind: "loading" }; + if (results.some((r) => r.isError)) return { kind: "error" }; + return { + kind: "ready", + counts: countApprovals(results.flatMap((r) => r.data?.approvals ?? [])), + }; +} + +export function ApprovalsCard({ + tenantId, + runs, + capped, +}: { + readonly tenantId: string; + readonly runs: readonly InsightsRun[]; + readonly capped: boolean; +}) { + const tally = useApprovalTally(tenantId, runs); + return ( +
+

Approvals

+

+ How you answered when a worker asked to act. + {capped ? ` Based on the ${runs.length} most recent runs.` : ""} +

+ {tally.kind === "loading" ? ( + + ) : tally.kind === "error" ? ( +

Couldn't load approvals.

+ ) : ( +
+ {ROWS.map(([key, label, color]) => { + const max = Math.max(...Object.values(tally.counts), 1); + return ( +
+ {label} + {tally.counts[key]} + +
+ ); + })} +
+ )} +
+ ); +} + +/** One-line drawer version of the Approvals card. */ +export function ApprovalsLine({ + tenantId, + runs, +}: { + readonly tenantId: string; + readonly runs: readonly InsightsRun[]; +}) { + const tally = useApprovalTally(tenantId, runs); + if (tally.kind === "loading") return ; + if (tally.kind === "error") return

Couldn't load approvals.

; + return ( +
+ {ROWS.map(([key, label, color]) => ( + + + {label} {tally.counts[key]} + + ))} +
+ ); +} diff --git a/apps/web/src/bench/insights-tab.tsx b/apps/web/src/bench/insights-tab.tsx index 91c18fd72..a7463b4d7 100644 --- a/apps/web/src/bench/insights-tab.tsx +++ b/apps/web/src/bench/insights-tab.tsx @@ -2,10 +2,16 @@ import { RichEmptyState, Skeleton } from "@corbits/react-ui"; import { useAPIQuery } from "../api"; import { insightsTopLevelRunsPath, TopLevelRunsSchema } from "../insights-api"; -import { computeBenchInsights, durationLabel, formatCount } from "../insights-stats"; +import { + approvalRunsInRange, + computeBenchInsights, + durationLabel, + formatCount, +} from "../insights-stats"; import { Link } from "../navigation"; import { workbenchInsightsPath } from "../insights-deeplinks"; import { benchLink } from "../shell/page-crumbs"; +import { ApprovalsLine } from "./approvals-insights"; import { OutcomeChart } from "../pages/bench-insights"; /** The bench Insights page in miniature: the same runs query and @@ -53,6 +59,15 @@ export function InsightsTab({ workbenchTenantId }: { readonly workbenchTenantId: +
+
+

Approvals

+
+ +
{ + const t = Date.parse(run.createdAt); + return !Number.isNaN(t) && t >= start && t <= now; + }) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + return { runs: inRange.slice(0, APPROVAL_RUN_CAP), capped: inRange.length > APPROVAL_RUN_CAP }; +} + +export type ApprovalCounts = { + readonly once: number; + readonly always: number; + readonly denied: number; + readonly waiting: number; +}; + +/** Timed-out and expired approvals were never answered, so they join no bucket. */ +export function countApprovals(approvals: readonly Approval[]): ApprovalCounts { + let once = 0; + let always = 0; + let denied = 0; + let waiting = 0; + for (const a of approvals) { + if (a.status === "pending") waiting += 1; + else if (a.status === "rejected") denied += 1; + else if (a.status === "approved") { + if (a.scope === "always") always += 1; + else once += 1; + } + } + return { once, always, denied, waiting }; +} diff --git a/apps/web/src/pages/bench-insights.tsx b/apps/web/src/pages/bench-insights.tsx index 58445913c..d0159a6ab 100644 --- a/apps/web/src/pages/bench-insights.tsx +++ b/apps/web/src/pages/bench-insights.tsx @@ -4,9 +4,11 @@ import { Badge, RichEmptyState, Skeleton, RUN_STATUS_TONE } from "@corbits/react-ui"; import { useState, type ReactNode } from "react"; +import { ApprovalsCard } from "../bench/approvals-insights"; import { useAPIQuery } from "../api"; import { insightsTopLevelRunsPath, TopLevelRunsSchema, type InsightsRun } from "../insights-api"; import { + approvalRunsInRange, BENCH_RANGES, computeBenchInsights, durationLabel, @@ -201,7 +203,9 @@ export function BenchInsights({ /> ); } else { - body = ; + body = ( + + ); } return ( @@ -217,15 +221,18 @@ export function BenchInsights({ } function InsightsBody({ + tenantId, runs, range, onOpenRun, }: { + readonly tenantId: string; readonly runs: { readonly data: readonly InsightsRun[]; readonly nextCursor: string | null }; readonly range: BenchRange; readonly onOpenRun: (id: string) => void; }) { const stats = computeBenchInsights(runs.data, range); + const approvalRuns = approvalRunsInRange(runs.data, range); const finished = stats.ok + stats.fail; const tiles: readonly (readonly [string, string])[] = [ ["Runs", formatCount(stats.total)], @@ -254,6 +261,8 @@ function InsightsBody({ + +

By workflow

{stats.workflows.length === 0 ? (