diff --git a/apps/web/src/app.css b/apps/web/src/app.css index bc3df6a70..4593fb448 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -2359,191 +2359,6 @@ a.drawer-li:hover, background: var(--card, var(--background)); color: var(--foreground); } -.bi-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 0.75rem; -} -.bi-stat { - padding: 1rem; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--card, var(--background)); -} -.bi-stat-v { - font-size: 1.6rem; - font-weight: 700; - font-variant-numeric: tabular-nums; -} -.bi-stat-k { - font-size: 0.75rem; - color: var(--muted-foreground); -} -.bi-chart { - position: relative; -} -.bi-legend { - display: flex; - gap: 1rem; - font-size: 0.78rem; - margin-bottom: 0.5rem; -} -.bi-legend span { - display: inline-flex; - align-items: center; - gap: 0.35rem; -} -.bi-legend i { - width: 10px; - height: 10px; - border-radius: 3px; - display: inline-block; -} -.bi-bars { - display: flex; - align-items: flex-end; - height: 160px; - padding-top: 8px; - border-bottom: 1px solid var(--border); -} -.bi-col { - flex: 1; - height: 100%; - display: flex; - flex-direction: column-reverse; - align-items: center; - gap: 2px; -} -.bi-col i { - display: block; - width: 100%; - max-width: 28px; -} -.bi-col:hover i, -.bi-col:focus-visible i { - filter: brightness(1.12); -} -.bi-col:focus-visible { - outline: 2px solid var(--ring, currentColor); - outline-offset: 1px; -} -.bi-x { - display: flex; - margin-top: 6px; - font-size: 0.7rem; - color: var(--muted-foreground); - font-variant-numeric: tabular-nums; -} -.bi-x span { - flex: 1; - text-align: center; -} -.bi-tip { - position: absolute; - top: 20px; - transform: translateX(-50%); - pointer-events: none; - z-index: 5; - padding: 0.4rem 0.6rem; - font-size: 0.78rem; - white-space: nowrap; - background: var(--popover, var(--card)); - border: 1px solid var(--border); - border-radius: 6px; -} -.bi-tip b { - display: block; -} -.bi-scroll { - overflow-x: auto; -} -.bi-table { - width: 100%; - min-width: 560px; - border-collapse: collapse; - font-size: 0.85rem; -} -.bi-table th { - text-align: left; - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--muted-foreground); - padding: 0.4rem 0.5rem; -} -.bi-table td { - padding: 0.55rem 0.5rem; - border-top: 1px solid var(--border); - font-variant-numeric: tabular-nums; -} -.bi-meter { - display: inline-flex; - gap: 2px; - width: 72px; - height: 8px; - margin-right: 8px; - vertical-align: middle; - border-radius: 4px; - overflow: hidden; - background: var(--muted); -} -.bi-meter i { - display: block; - height: 100%; -} -.bi-fails { - list-style: none; - margin: 0; - padding: 0; -} -.bi-fails button { - display: flex; - align-items: center; - gap: 0.75rem; - width: 100%; - padding: 0.6rem 0; - text-align: left; - border-top: 1px solid var(--border); -} -.bi-fails li:first-child button { - border-top: 0; -} -.bi-fails span { - margin-left: auto; - font-size: 0.78rem; - color: var(--muted-foreground); -} -.bi-later-h { - margin: 0.5rem 0 0.25rem; - font-size: 0.85rem; - font-weight: 700; -} -.bi-soon { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.75rem; - margin-top: 0.75rem; -} -.bi-soon > div { - padding: 1rem; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--muted); -} -.bi-soon b { - display: block; - font-size: 0.85rem; - margin-bottom: 0.25rem; -} -.bi-soon span { - font-size: 0.78rem; - color: var(--muted-foreground); -} -@media (max-width: 960px) { - .bi-soon { - grid-template-columns: 1fr; - } -} /* Sidebar bottom: profile button, Tools button, account menu. */ .sb-bottom { diff --git a/apps/web/src/bench/approvals-insights.tsx b/apps/web/src/bench/approvals-insights.tsx index f18905941..e020151c3 100644 --- a/apps/web/src/bench/approvals-insights.tsx +++ b/apps/web/src/bench/approvals-insights.tsx @@ -44,16 +44,16 @@ export function ApprovalsCard({ }) { 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.

+

Couldn't load approvals.

) : (
{ROWS.map(([key, label, color]) => { diff --git a/apps/web/src/insights-stats.ts b/apps/web/src/insights-stats.ts index a6436d94c..ec4332bb1 100644 --- a/apps/web/src/insights-stats.ts +++ b/apps/web/src/insights-stats.ts @@ -97,6 +97,7 @@ export type BenchDay = { export type BenchWorkflowRow = { readonly key: string; readonly name: string; + readonly definitionName: string; readonly runs: number; readonly ok: number; readonly fail: number; @@ -190,6 +191,7 @@ export function computeBenchInsights( return { key, name: newest[0] !== undefined ? runDisplayName(newest[0]) : key, + definitionName: newest[0]?.definitionName ?? key, runs: rs.length, ok: rs.filter((r) => bucketOf(r, now) === "ok").length, fail: rs.filter((r) => bucketOf(r, now) === "fail").length, diff --git a/apps/web/src/pages/bench-insights.css b/apps/web/src/pages/bench-insights.css index e163cac32..773ae0a38 100644 --- a/apps/web/src/pages/bench-insights.css +++ b/apps/web/src/pages/bench-insights.css @@ -1,20 +1,281 @@ -/* Card and table labels read as sentence case, like the drawer's tabs. */ -.page-layout .insights-panel h3 { - font-size: 0.9rem; +/* Bench Insights page and its drawer tab: joined stat strip, raised panels, + chart, table, failures and the coming-later trio. */ +.bi-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + background: var(--surface); + border-radius: var(--r-lg); + box-shadow: var(--raised); + overflow: hidden; +} +.bi-stat { + padding: 16px 20px; +} +.bi-stat + .bi-stat { + border-left: 1px solid var(--line); +} +.bi-stat-v { + font-size: 24px; + font-weight: 800; + font-variant-numeric: tabular-nums; +} +.bi-stat-k { + font-size: 12.5px; + font-weight: 600; + color: var(--ink-3); +} + +.bi-page { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} +.bi-grid { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + gap: 12px; +} +.bi-panel { + min-width: 0; + padding: 20px; + background: var(--surface); + border-radius: var(--r-lg); + box-shadow: var(--raised); +} +.bi-panel--flush { + padding: 0; + overflow: hidden; +} +.bi-panel-head { + padding: 20px 20px 12px; +} +.bi-panel h3 { + margin: 0 0 4px; + font-size: 16px; + font-weight: 800; letter-spacing: 0; text-transform: none; color: var(--ink); } -.page-layout .bi-table th { +.bi-sub { + margin: 0 0 16px; + font-size: 13.5px; + color: var(--ink-3); +} +.bi-panel-head .bi-sub { + margin: 0; +} +.bi-empty { + margin: 0; + padding: 24px 0; + text-align: center; + font-size: 13.5px; + color: var(--ink-3); +} +.bi-panel--flush .bi-empty { + padding: 8px 20px 24px; +} + +.bi-chart { + position: relative; +} +.bi-chart-empty { + display: grid; + place-items: center; + height: 160px; + font-size: 13.5px; + color: var(--ink-3); + border-bottom: 1px solid var(--border); +} +.bi-legend { + display: flex; + gap: 1rem; font-size: 0.78rem; - letter-spacing: 0; - text-transform: none; + margin-bottom: 0.5rem; +} +.bi-legend span { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} +.bi-legend i { + width: 10px; + height: 10px; + border-radius: 3px; + display: inline-block; +} +.bi-bars { + display: flex; + align-items: flex-end; + height: 160px; + padding-top: 8px; + border-bottom: 1px solid var(--border); +} +.bi-col { + flex: 1; + height: 100%; + display: flex; + flex-direction: column-reverse; + align-items: center; + gap: 2px; +} +.bi-col i { + display: block; + width: 100%; + max-width: 28px; +} +.bi-col:hover i, +.bi-col:focus-visible i { + filter: brightness(1.12); +} +.bi-col:focus-visible { + outline: 2px solid var(--ring, currentColor); + outline-offset: 1px; } .bi-other { background: var(--muted-foreground); opacity: 0.55; } -.bi-legend .bi-other { - width: 10px; - height: 10px; +.bi-x { + display: flex; + margin-top: 6px; + font-size: 0.7rem; + color: var(--muted-foreground); + font-variant-numeric: tabular-nums; +} +.bi-x span { + flex: 1; + text-align: center; +} +.bi-tip { + position: absolute; + top: 20px; + transform: translateX(-50%); + pointer-events: none; + z-index: 5; + padding: 0.4rem 0.6rem; + font-size: 0.78rem; + white-space: nowrap; + background: var(--popover, var(--card)); + border: 1px solid var(--border); + border-radius: 6px; +} +.bi-tip b { + display: block; +} + +.bi-scroll { + overflow-x: auto; +} +.bi-table { + width: 100%; + min-width: 640px; + border-collapse: collapse; + font-size: 0.85rem; +} +.bi-table th { + text-align: left; + font-size: 12.5px; + font-weight: 600; + color: var(--ink-3); + padding: 8px 20px; +} +.bi-table td { + padding: 10px 20px; + border-top: 1px solid var(--line); + font-variant-numeric: tabular-nums; +} +.bi-paused { + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + background: var(--surface-sunk); + color: var(--ink-3); +} +.bi-meter { + display: inline-flex; + gap: 2px; + width: 72px; + height: 8px; + margin-right: 8px; + vertical-align: middle; + border-radius: 4px; + overflow: hidden; + background: var(--muted); +} +.bi-meter i { + display: block; + height: 100%; +} + +.bi-fails { + list-style: none; + margin: 0; + padding: 0; +} +.bi-fails button { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px 20px; + text-align: left; + border-top: 1px solid var(--line); +} +.bi-fails button:hover { + background: var(--surface-sunk); +} +.bi-fails .bi-x-ic { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 8px; + background: var(--danger-wash); + color: var(--danger); +} +.bi-fails b { + display: block; + font-size: 13.5px; +} +.bi-fails .bi-why, +.bi-fails .bi-when { + display: block; + font-size: 12.5px; + color: var(--ink-3); +} + +.bi-later-h { + margin: 12px 0 4px; + font-size: 16px; + font-weight: 800; +} +.bi-soon { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 12px; +} +.bi-soon > div { + padding: 16px; + border-radius: var(--r-lg); + box-shadow: inset 0 0 0 1px var(--line); + background: var(--surface-sunk); +} +.bi-soon b { + display: block; + font-size: 13.5px; + margin-bottom: 4px; +} +.bi-soon span { + font-size: 12.5px; + color: var(--ink-3); +} +@media (max-width: 960px) { + .bi-soon, + .bi-grid { + grid-template-columns: 1fr; + } } diff --git a/apps/web/src/pages/bench-insights.tsx b/apps/web/src/pages/bench-insights.tsx index d0159a6ab..2e498d7c9 100644 --- a/apps/web/src/pages/bench-insights.tsx +++ b/apps/web/src/pages/bench-insights.tsx @@ -1,12 +1,21 @@ // The bench-scoped Insights dashboard: every number is computed from the // stock `GET /workflows/runs` listing of the bench's own tenant. -import { Badge, RichEmptyState, Skeleton, RUN_STATUS_TONE } from "@corbits/react-ui"; +import { RichEmptyState, Skeleton } from "@corbits/react-ui"; +import { cronSentence } from "@corbits/workflows/client"; +import { useQueries, useQuery } from "@tanstack/react-query"; 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 { apiQueryOptions, ArtifactListPageSchema, useAPIQuery } from "../api"; +import { + insightsRunEventsPath, + insightsTopLevelRunsPath, + RunEventsSchema, + runFailureMessage, + TopLevelRunsSchema, + type InsightsRun, +} from "../insights-api"; import { approvalRunsInRange, BENCH_RANGES, @@ -17,6 +26,9 @@ import { type BenchDay, type BenchRange, } from "../insights-stats"; +import { X } from "../lib/icons"; +import { tenantKeys } from "../query-client"; +import { listCronSchedules } from "../routines-api"; import { formatWhen } from "./insights-page"; import { useFromBench } from "../shell/page-crumbs"; import { PageLayout } from "../shell/page-layout"; @@ -50,6 +62,9 @@ export function OutcomeChart({ days }: { readonly days: readonly BenchDay[] }) { const max = Math.max(...days.map((d) => d.ok + d.fail + d.other), 1); const every = days.length > 30 ? 14 : days.length > 7 ? 5 : 1; const shown = active === null ? undefined : days[active]; + if (days.every((d) => d.ok + d.fail + d.other === 0)) { + return
No runs in this range.
; + } return (
@@ -61,10 +76,6 @@ export function OutcomeChart({ days }: { readonly days: readonly BenchDay[] }) { Failed - - - Running or stopped -
30 ? 1 : 4 }}> {days.map((d, i) => ( @@ -234,16 +245,41 @@ function InsightsBody({ const stats = computeBenchInsights(runs.data, range); const approvalRuns = approvalRunsInRange(runs.data, range); const finished = stats.ok + stats.fail; + const artifacts = useAPIQuery(`/api/tenants/${tenantId}/artifacts`, ArtifactListPageSchema); + const schedules = useQuery({ + queryKey: tenantKeys.schedules(tenantId), + queryFn: () => listCronSchedules(tenantId), + }); + const scheduleByDefinition = new Map( + (schedules.data ?? []) + .filter((row) => row.stoppedAt === null) + .map((row) => [row.definitionName, row]), + ); + const failureEvents = useQueries({ + queries: stats.failures.map((run) => + apiQueryOptions(insightsRunEventsPath(tenantId, run.id), RunEventsSchema), + ), + }); + const rangeStart = stats.days[0]?.date.getTime() ?? 0; + const savedCount = + artifacts.kind === "ready" + ? formatCount( + artifacts.data.artifacts.filter( + (a) => a.archivedAt === null && Date.parse(a.createdAt) >= rangeStart, + ).length, + ) + : "—"; const tiles: readonly (readonly [string, string])[] = [ ["Runs", formatCount(stats.total)], ["Succeeded", finished === 0 ? "—" : `${Math.round((stats.ok / finished) * 100)}%`], ["Median run", stats.medianMs === null ? "—" : durationLabel(stats.medianMs)], + ["Artifacts saved", savedCount], ]; return ( -
+
{runs.nextCursor !== null ? ( -

Figures reflect the 100 most recent runs — more exist.

+

Figures reflect the 100 most recent runs — more exist.

) : null}
@@ -255,21 +291,26 @@ function InsightsBody({ ))}
-
-

Runs per day

-

Every run in this workbench, by how it ended.

- -
- - +
+
+

Runs per day

+

+ Every workflow and conversation run in this workbench, by how it ended. +

+ +
+ +
-
-

By workflow

+
+
+

By workflow

+

+ Median time from start to finish. Next run comes from the schedule. +

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

Nothing ran in the last {range} days.

) : (
@@ -280,6 +321,7 @@ function InsightsBody({ + @@ -303,6 +345,14 @@ function InsightsBody({ + ); })} @@ -312,28 +362,40 @@ function InsightsBody({ )} -
-

Recent failures

+
+
+

Recent failures

+

Open one to see the run and retry it.

+
{stats.failures.length === 0 ? ( -

No failures in the last {range} days.

+

No failures in the last {range} days.

) : (
    - {stats.failures.map((run) => ( -
  • - -
  • - ))} + {stats.failures.map((run, i) => { + const events = failureEvents[i]?.data?.events; + const why = events === undefined ? null : runFailureMessage(events); + return ( +
  • + +
  • + ); + })}
)}

Coming later

-

These need usage data the platform doesn't report yet.

+

These need usage data the platform doesn't report yet.

{COMING_LATER.map(([title, detail]) => (
diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index 979aea514..146e1e7cd 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -462,7 +462,7 @@ function InsightsWorkbenchPage({ } // `/insights` with no workbench hops to the last-visited workbench's -// Insights, or the new-workbench picker when none is on record. +// Insights, else the first workbench's, else the new-workbench picker. function InsightsLandingRedirect({ benchTenantId }: { readonly benchTenantId: string | null }) { const navigate = useNavigate(); const { workbenches, isLoading } = useWorkbenchList(benchTenantId); @@ -474,10 +474,8 @@ function InsightsLandingRedirect({ benchTenantId }: { readonly benchTenantId: st ); } const lastId = benchTenantId === null ? null : readLastWorkbenchId(benchTenantId); - const to = - lastId !== null && workbenches.some((workbench) => workbench.id === lastId) - ? workbenchInsightsPath(lastId) - : NEW_WORKBENCH_PATH; + const target = workbenches.find((workbench) => workbench.id === lastId) ?? workbenches[0]; + const to = target === undefined ? NEW_WORKBENCH_PATH : workbenchInsightsPath(target.id); return ; }
Succeeded Median Last runNext run
{w.medianMs === null ? "—" : durationLabel(w.medianMs)} {formatWhen(w.lastRun)} + {(() => { + const row = scheduleByDefinition.get(w.definitionName); + if (row === undefined) return "—"; + if (!row.enabled) return Paused; + return cronSentence(row.expression) ?? row.expression; + })()} +