Skip to content
Merged
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
206 changes: 206 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -3942,3 +3942,209 @@ tr.insights-row-clickable:hover {
font-size: 0.95rem;
font-weight: 600;
}

/* Bench Insights (pages/bench-insights.tsx) */
.bi-seg {
display: inline-flex;
gap: 2px;
padding: 2px;
border-radius: 8px;
background: var(--muted);
align-self: flex-start;
}
.bi-seg button {
padding: 0.25rem 0.7rem;
font-size: 0.78rem;
font-weight: 600;
border-radius: 6px;
color: var(--muted-foreground);
}
.bi-seg button.active {
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;
}
}
46 changes: 46 additions & 0 deletions apps/web/src/bench-insights-stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";

import type { InsightsRun } from "./insights-api";
import { computeBenchInsights, medianMs } from "./insights-stats";

const NOW = Date.parse("2026-09-10T12:00:00");

function run(id: string, createdAt: string, status: string, endedAt?: string): InsightsRun {
return {
id,
definitionId: "def_a",
definitionName: "brief",
createdAt,
status,
...(endedAt === undefined ? {} : { endedAt }),
} as unknown as InsightsRun;
}

describe("medianMs", () => {
test("averages the middle pair of an even set", () => {
expect(medianMs([4, 1, 3, 2])).toBe(2.5);
expect(medianMs([])).toBeNull();
});
});

describe("computeBenchInsights", () => {
test("windows, buckets by outcome and day, and takes median duration", () => {
const stats = computeBenchInsights(
[
run("a", "2026-09-10T09:00:00", "completed", "2026-09-10T09:01:00"),
run("b", "2026-09-10T10:00:00", "failed", "2026-09-10T10:03:00"),
run("c", "2026-09-09T10:00:00", "completed", "2026-09-09T10:02:00"),
run("old", "2026-08-01T10:00:00", "completed", "2026-08-01T10:02:00"),
],
7,
NOW,
);
expect(stats.total).toBe(3);
expect(stats.ok).toBe(2);
expect(stats.fail).toBe(1);
expect(stats.medianMs).toBe(120_000);
expect(stats.days.at(-1)).toMatchObject({ ok: 1, fail: 1 });
expect(stats.days.at(-2)).toMatchObject({ ok: 1, fail: 0 });
expect(stats.failures.map((r) => r.id)).toEqual(["b"]);
});
});
127 changes: 127 additions & 0 deletions apps/web/src/insights-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,130 @@ export function computeInsightsStats(
recentRuns,
};
}

export const BENCH_RANGES = [7, 30, 90] as const;
export type BenchRange = (typeof BENCH_RANGES)[number];

const DAY_MS = 86_400_000;

export type BenchDay = {
readonly date: Date;
readonly ok: number;
readonly fail: number;
};
export type BenchWorkflowRow = {
readonly key: string;
readonly name: string;
readonly runs: number;
readonly ok: number;
readonly fail: number;
readonly medianMs: number | null;
readonly lastRun: string;
};
export type BenchInsights = {
readonly total: number;
readonly ok: number;
readonly fail: number;
readonly medianMs: number | null;
readonly days: readonly BenchDay[];
readonly workflows: readonly BenchWorkflowRow[];
readonly failures: readonly InsightsRun[];
};

export function medianMs(values: readonly number[]): number | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const hi = sorted[mid] ?? 0;
return sorted.length % 2 === 1 ? hi : ((sorted[mid - 1] ?? 0) + hi) / 2;
}

function runDurationMs(run: InsightsRun): number | null {
if (run.endedAt === undefined || run.endedAt === null) return null;
const ms = Date.parse(run.endedAt) - Date.parse(run.createdAt);
return Number.isNaN(ms) || ms < 0 ? null : ms;
}

type Bucket = "ok" | "fail" | "other";
function bucketOf(run: InsightsRun, now: number): Bucket {
const outcome = runOutcomeStatus(withListingAbandoned(run, now), now) ?? run.status;
if (outcome === "completed") return "ok";
if (outcome === "failed" || outcome === "error") return "fail";
return "other";
}

/** Rollups over the runs created in the last `range` local days (today
* included). "Other" outcomes (running, stopped) count as runs but are
* neither succeeded nor failed. */
export function computeBenchInsights(
runs: readonly InsightsRun[],
range: BenchRange,
now: number = Date.now(),
): BenchInsights {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const days: { date: Date; ok: number; fail: number }[] = [];
for (let i = range - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(today.getDate() - i);
days.push({ date, ok: 0, fail: 0 });
}
const start = days[0]?.date.getTime() ?? 0;

const inRange = runs.filter((run) => {
const t = Date.parse(run.createdAt);
return !Number.isNaN(t) && t >= start && t <= now;
});
let ok = 0;
let fail = 0;
const groups = new Map<string, InsightsRun[]>();
for (const run of inRange) {
const bucket = bucketOf(run, now);
const midnight = new Date(run.createdAt);
midnight.setHours(0, 0, 0, 0);
// round, not floor: DST days are 23h or 25h long.
const day = days[Math.round((midnight.getTime() - start) / DAY_MS)];
if (bucket === "ok") {
ok += 1;
if (day !== undefined) day.ok += 1;
} else if (bucket === "fail") {
fail += 1;
if (day !== undefined) day.fail += 1;
}
const key = run.routineId ?? run.definitionId;
groups.set(key, [...(groups.get(key) ?? []), run]);
}

const durations = (rs: readonly InsightsRun[]) =>
rs.flatMap((r) => {
const d = runDurationMs(r);
return d === null ? [] : [d];
});
const workflows = [...groups.entries()]
.map(([key, rs]) => {
const newest = [...rs].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return {
key,
name: newest[0] !== undefined ? runDisplayName(newest[0]) : key,
runs: rs.length,
ok: rs.filter((r) => bucketOf(r, now) === "ok").length,
fail: rs.filter((r) => bucketOf(r, now) === "fail").length,
medianMs: medianMs(durations(rs)),
lastRun: newest[0]?.createdAt ?? "",
};
})
.sort((a, b) => b.lastRun.localeCompare(a.lastRun));

return {
total: inRange.length,
ok,
fail,
medianMs: medianMs(durations(inRange)),
days,
workflows,
failures: inRange
.filter((r) => bucketOf(r, now) === "fail")
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, 5),
};
}
Loading
Loading