mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
feat(lens): live dot field timeline and full-screen traces view (#44390)
* feat(lens): add dot field layout and live status helpers for the traces timeline * test(lens): cover dot field layout, agent colors and live status * feat(lens): draw the traces timeline as a live dot field * feat(lens): add the sweep animation for the live traces timeline * feat(lens): put the lens tabs in a compact header and fill the screen with traces
This commit is contained in:
parent
ad8babae33
commit
f0abe1bea1
5 changed files with 423 additions and 66 deletions
|
|
@ -473,3 +473,12 @@
|
|||
[data-slot="dialog-content"][data-nested-dialog-open] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@keyframes lens-sweep {
|
||||
from {
|
||||
left: -6rem;
|
||||
}
|
||||
to {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,29 +69,31 @@ function LensContent({
|
|||
const openDemo = onDemo ? () => onDemo(activeTab) : undefined;
|
||||
return (
|
||||
<LensPreviewTarget.Provider value={previewTarget}>
|
||||
<main className="flex w-full min-w-0 flex-1 flex-col gap-5 p-6 md:p-8">
|
||||
<div className="flex min-h-9 flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<Aperture aria-hidden="true" className="size-7" strokeWidth={1.75} />
|
||||
Lens
|
||||
</h1>
|
||||
<div ref={setPreviewTarget} />
|
||||
</div>
|
||||
<main className="flex min-h-full w-full min-w-0 flex-1 flex-col gap-2 px-3 pt-2 pb-3">
|
||||
{demo && <DemoNotice onExit={onExit} />}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => (demo ? setDemoTab(value as Tab) : void setTab(value as Tab))}
|
||||
className="min-h-0 flex-1 gap-4"
|
||||
className="min-h-0 flex-1 gap-2"
|
||||
>
|
||||
<TabsList variant="line" aria-label="Lens" className="w-full justify-start gap-6 border-b px-0">
|
||||
<TabsTrigger value="traces" className="flex-none px-0">
|
||||
Traces
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="investigations" className="flex-none px-0">
|
||||
Investigations
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="traces" keepMounted className="min-h-0">
|
||||
<div className="flex min-h-8 flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="flex items-center gap-1.5 text-sm font-semibold tracking-tight">
|
||||
<Aperture aria-hidden="true" className="size-4" strokeWidth={2} />
|
||||
Lens
|
||||
</h1>
|
||||
<TabsList aria-label="Lens" className="h-8">
|
||||
<TabsTrigger value="traces" className="px-3">
|
||||
Traces
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="investigations" className="px-3">
|
||||
Investigations
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div ref={setPreviewTarget} />
|
||||
</div>
|
||||
<TabsContent value="traces" keepMounted className="flex min-h-0 flex-col">
|
||||
<AgentTracesPage
|
||||
accessToken={accessToken}
|
||||
isActive={activeTab === "traces"}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { FIELD_COLS, FIELD_ROWS, agentDotColor, agoLabel, columnDots, isReceiving } from "./lensField";
|
||||
import type { TraceSummary } from "./traceTypes";
|
||||
import { traceAgentNames } from "./traceUtils";
|
||||
|
||||
const BUCKETS = 60;
|
||||
const TICKS = 6;
|
||||
|
|
@ -24,6 +26,7 @@ export interface Bucket {
|
|||
endMs: number;
|
||||
runs: number;
|
||||
failed: number;
|
||||
agents: readonly string[];
|
||||
}
|
||||
|
||||
/** Run counts per equal-width time bucket across the window; runs outside it are dropped. */
|
||||
|
|
@ -32,6 +35,7 @@ export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buc
|
|||
const placed = runs.map((run) => ({
|
||||
index: Math.floor((moment(run.start_time).valueOf() - range.startMs) / width),
|
||||
failed: run.error_count > 0,
|
||||
agent: traceAgentNames(run)[0] ?? "",
|
||||
}));
|
||||
return Array.from({ length: buckets }, (_, i) => {
|
||||
const hits = placed.filter((p) => p.index === i);
|
||||
|
|
@ -40,6 +44,7 @@ export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buc
|
|||
endMs: range.startMs + (i + 1) * width,
|
||||
runs: hits.length,
|
||||
failed: hits.filter((p) => p.failed).length,
|
||||
agents: hits.filter((p) => !p.failed).map((p) => p.agent),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -73,44 +78,202 @@ interface TracesTimelineProps {
|
|||
onSelect: (selection: TimeWindow | null) => void;
|
||||
}
|
||||
|
||||
function BucketBar({
|
||||
bucket,
|
||||
max,
|
||||
dimmed,
|
||||
hovered,
|
||||
}: {
|
||||
bucket: Bucket;
|
||||
const FAILED_RED = "#e5484d";
|
||||
const DOT_PITCH = 6;
|
||||
const FIELD_HEIGHT = FIELD_ROWS * DOT_PITCH;
|
||||
|
||||
function fieldDotColor(lit: boolean, dot: ReturnType<typeof columnDots>[number], grid: string): string {
|
||||
if (!lit) return grid;
|
||||
if (dot.kind === "failed") return FAILED_RED;
|
||||
return dot.kind === "run" ? dot.color : grid;
|
||||
}
|
||||
|
||||
interface FieldFrame {
|
||||
buckets: readonly Bucket[];
|
||||
max: number;
|
||||
dimmed: boolean;
|
||||
hovered: boolean;
|
||||
band: Band | null;
|
||||
hover: number | null;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
function dotRadius(lit: boolean, hovered: boolean): number {
|
||||
if (!lit) return 0.9;
|
||||
return hovered ? 2.4 : 1.9;
|
||||
}
|
||||
|
||||
function drawField(canvas: HTMLCanvasElement, { buckets, max, band, hover, progress }: FieldFrame) {
|
||||
const context = canvas.getContext("2d");
|
||||
const width = canvas.clientWidth;
|
||||
if (!context || width === 0) return;
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(FIELD_HEIGHT * ratio);
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.clearRect(0, 0, width, FIELD_HEIGHT);
|
||||
const grid = document.documentElement.classList.contains("dark") ? "rgba(255,255,255,0.08)" : "rgba(15,23,42,0.08)";
|
||||
const columnWidth = width / buckets.length;
|
||||
const pitch = columnWidth / FIELD_COLS;
|
||||
buckets.forEach((bucket, i) => {
|
||||
const dots = columnDots(bucket, max, i * 7919 + bucket.runs);
|
||||
const visible = Math.ceil(dots.length * progress);
|
||||
const dimmed = band !== null && (i < band.lo || i > band.hi);
|
||||
dots.forEach((dot, index) => {
|
||||
const lit = dot.kind !== "grid" && index < visible;
|
||||
context.globalAlpha = lit && dimmed ? 0.15 : 1;
|
||||
context.fillStyle = fieldDotColor(lit, dot, grid);
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
i * columnWidth + pitch * ((index % FIELD_COLS) + 0.5),
|
||||
FIELD_HEIGHT - DOT_PITCH * (Math.floor(index / FIELD_COLS) + 0.5),
|
||||
dotRadius(lit, hover === i),
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.fill();
|
||||
});
|
||||
});
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function useRiseIn(): number {
|
||||
const [progress, setProgress] = useState(0);
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setProgress(1);
|
||||
return;
|
||||
}
|
||||
const start = performance.now();
|
||||
let frame = requestAnimationFrame(function rise(now) {
|
||||
const t = Math.min(1, (now - start) / 700);
|
||||
setProgress(1 - (1 - t) ** 3);
|
||||
if (t < 1) frame = requestAnimationFrame(rise);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
return progress;
|
||||
}
|
||||
|
||||
function DotField({
|
||||
buckets,
|
||||
max,
|
||||
band,
|
||||
hover,
|
||||
}: {
|
||||
buckets: readonly Bucket[];
|
||||
max: number;
|
||||
band: Band | null;
|
||||
hover: number | null;
|
||||
}) {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const progress = useRiseIn();
|
||||
useEffect(() => {
|
||||
const node = canvas.current;
|
||||
if (!node) return;
|
||||
const frame: FieldFrame = { buckets, max, band, hover, progress };
|
||||
const paint = () => drawField(node, frame);
|
||||
paint();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(paint);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [buckets, max, band, hover, progress]);
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none flex h-full flex-1 flex-col justify-end px-px"
|
||||
data-testid="timeline-bucket"
|
||||
data-runs={bucket.runs}
|
||||
>
|
||||
{bucket.runs > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-[10px] flex-col overflow-hidden rounded-t-[2px]",
|
||||
dimmed && "bg-muted-foreground/25",
|
||||
!dimmed && (hovered ? "bg-info" : "bg-info/65"),
|
||||
)}
|
||||
style={{ height: `${Math.max(6, (bucket.runs / max) * 100)}%` }}
|
||||
>
|
||||
{bucket.failed > 0 && (
|
||||
<div
|
||||
className="w-full shrink-0 bg-destructive"
|
||||
style={{ height: `${Math.max(2, Math.min(25, (bucket.failed / bucket.runs) * 100))}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<canvas
|
||||
ref={canvas}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 w-full"
|
||||
style={{ height: FIELD_HEIGHT }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BucketBar({ bucket }: { bucket: Bucket }) {
|
||||
return <div className="pointer-events-none h-full flex-1" data-testid="timeline-bucket" data-runs={bucket.runs} />;
|
||||
}
|
||||
|
||||
function useNow(intervalMs: number): number {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), intervalMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
function LivePulse({ live }: { live: boolean }) {
|
||||
return (
|
||||
<span aria-hidden="true" className="relative flex size-2">
|
||||
{live && (
|
||||
<span className="absolute inline-flex size-full rounded-full bg-[#3b5bfd] opacity-60 motion-safe:animate-ping" />
|
||||
)}
|
||||
<span
|
||||
className={cn("relative inline-flex size-2 rounded-full", live ? "bg-[#3b5bfd]" : "bg-muted-foreground/50")}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function latestStart(runs: readonly TraceSummary[]): number | null {
|
||||
return runs.reduce<number | null>((latest, run) => {
|
||||
const t = moment(run.start_time).valueOf();
|
||||
return latest === null || t > latest ? t : latest;
|
||||
}, null);
|
||||
}
|
||||
|
||||
function FieldHeader({ runs, range }: { runs: readonly TraceSummary[]; range: TimeWindow }) {
|
||||
const now = useNow(1000);
|
||||
const agents = Array.from(new Set(runs.flatMap(traceAgentNames)));
|
||||
const failed = runs.filter((run) => run.error_count > 0).length;
|
||||
const lastMs = latestStart(runs);
|
||||
const live = isReceiving(lastMs, now);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-1 font-mono text-[11px] leading-none">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground tabular-nums">
|
||||
<span className="flex items-center gap-2" role="status" data-testid="traces-last-seen">
|
||||
<LivePulse live={live} />
|
||||
<span className={cn("font-semibold", live ? "text-[#0011b3] dark:text-[#8b9bff]" : "text-foreground")}>
|
||||
{live ? "receiving" : "idle"}
|
||||
</span>
|
||||
{lastMs !== null && <span>last trace {agoLabel(lastMs, now)}</span>}
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold text-foreground">{runs.length.toLocaleString()}</span>{" "}
|
||||
{runs.length === 1 ? "run" : "runs"} from{" "}
|
||||
<span className="font-semibold text-foreground">{agents.length}</span>{" "}
|
||||
{agents.length === 1 ? "agent" : "agents"}
|
||||
{failed > 0 && <span className="text-[#e5484d]"> · {failed} failed</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 text-muted-foreground">
|
||||
{agents.slice(0, 6).map((agent) => (
|
||||
<span key={agent} className="flex items-center gap-1.5">
|
||||
<span className="size-[6px] rounded-full" style={{ backgroundColor: agentDotColor(agent) }} />
|
||||
{agent}
|
||||
</span>
|
||||
))}
|
||||
<span className="rounded border border-border bg-muted/50 px-1.5 py-px">
|
||||
Total {formatSpan(range.endMs - range.startMs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NowEdge() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 w-24 bg-gradient-to-r from-transparent via-[#3b5bfd]/[0.08] to-transparent motion-safe:animate-[lens-sweep_6s_linear_infinite] motion-reduce:hidden"
|
||||
/>
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0">
|
||||
<span className="absolute inset-y-0 right-0 w-px bg-[#3b5bfd]/50" />
|
||||
<span className="absolute -top-0.5 right-0 size-1.5 translate-x-1/2 rounded-full bg-[#3b5bfd] motion-safe:animate-pulse" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -125,7 +288,7 @@ function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
|
|||
{bucket.runs} {bucket.runs === 1 ? "run" : "runs"}
|
||||
{bucket.failed > 0 && `, ${bucket.failed} failed`}
|
||||
</div>
|
||||
<div className="text-info">drag to zoom</div>
|
||||
<div className="text-[#0011b3] dark:text-[#8b9bff]">drag to zoom</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -185,29 +348,31 @@ function SelectionBracket({
|
|||
const leftFrac = band.lo / BUCKETS;
|
||||
const rightFrac = (band.hi + 1) / BUCKETS;
|
||||
const widthPx = (rightFrac - leftFrac) * stripWidth;
|
||||
const handle = "absolute inset-y-0 w-[3px] cursor-ew-resize bg-info";
|
||||
const handle = "absolute inset-y-0 w-[2px] cursor-ew-resize bg-[#0011b3] dark:bg-[#8b9bff]";
|
||||
const tick =
|
||||
"before:absolute before:top-0 before:h-[2px] before:w-2 before:bg-inherit after:absolute after:bottom-0 after:h-[2px] after:w-2 after:bg-inherit";
|
||||
const edgeLabel =
|
||||
"pointer-events-none absolute -top-4 font-mono text-[10px] whitespace-nowrap text-info tabular-nums";
|
||||
"pointer-events-none absolute -bottom-5 font-mono text-[10px] whitespace-nowrap text-[#0011b3] tabular-nums dark:text-[#8b9bff]";
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-y-0 cursor-grab rounded-[2px] border border-info bg-info/10 active:cursor-grabbing"
|
||||
className="absolute inset-y-0 cursor-grab bg-[#0011b3]/[0.05] active:cursor-grabbing dark:bg-[#8b9bff]/[0.08]"
|
||||
style={{ left: pct(leftFrac), width: pct(rightFrac - leftFrac) }}
|
||||
data-testid="timeline-selection"
|
||||
onPointerDown={onHandleDown("move")}
|
||||
>
|
||||
<span
|
||||
className={cn(handle, "-left-px")}
|
||||
className={cn(handle, tick, "-left-px before:left-0 after:left-0")}
|
||||
data-testid="timeline-handle-lo"
|
||||
onPointerDown={onHandleDown("resize-lo")}
|
||||
/>
|
||||
<span
|
||||
className={cn(handle, "-right-px")}
|
||||
className={cn(handle, tick, "-right-px before:right-0 after:right-0")}
|
||||
data-testid="timeline-handle-hi"
|
||||
onPointerDown={onHandleDown("resize-hi")}
|
||||
/>
|
||||
{widthPx >= MIN_DURATION_LABEL_PX && (
|
||||
<span className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center font-mono text-[11px] font-medium text-info">
|
||||
<span className="pointer-events-none absolute inset-x-0 top-2 text-center font-mono text-[11px] font-semibold text-[#0011b3] dark:text-[#8b9bff]">
|
||||
{formatSpan(window.endMs - window.startMs)}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -312,7 +477,6 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
|
|||
onSelect(null);
|
||||
};
|
||||
|
||||
const isDimmed = (i: number): boolean => band !== null && (i < band.lo || i > band.hi);
|
||||
const labelFormat = edgeFormat(range);
|
||||
|
||||
return (
|
||||
|
|
@ -322,16 +486,13 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
|
|||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className="mb-4 flex items-center">
|
||||
<span className="rounded border border-border bg-muted/50 px-1.5 py-px font-mono text-[10px] text-muted-foreground">
|
||||
Total {formatSpan(range.endMs - range.startMs)}
|
||||
</span>
|
||||
</div>
|
||||
<FieldHeader runs={runs} range={range} />
|
||||
<div
|
||||
ref={areaRef}
|
||||
role="presentation"
|
||||
data-testid="timeline-area"
|
||||
className="relative flex h-14 cursor-crosshair touch-none items-end border-b border-border"
|
||||
className="relative mt-2 flex cursor-crosshair touch-none items-end"
|
||||
style={{ height: FIELD_HEIGHT }}
|
||||
onPointerDown={(e) => begin("select", e)}
|
||||
onPointerMove={onMove}
|
||||
onPointerUp={onUp}
|
||||
|
|
@ -340,13 +501,15 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
|
|||
>
|
||||
{hover !== null && !drag && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-info"
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-[#0011b3]/50 dark:bg-[#8b9bff]/50"
|
||||
style={{ left: pct((hover + 0.5) / BUCKETS) }}
|
||||
data-testid="timeline-cursor"
|
||||
/>
|
||||
)}
|
||||
{buckets.map((b, i) => (
|
||||
<BucketBar key={b.startMs} bucket={b} max={max} dimmed={isDimmed(i)} hovered={hover === i} />
|
||||
<DotField buckets={buckets} max={max} band={band} hover={hover} />
|
||||
<NowEdge />
|
||||
{buckets.map((b) => (
|
||||
<BucketBar key={b.startMs} bucket={b} />
|
||||
))}
|
||||
{band && (
|
||||
<SelectionBracket
|
||||
|
|
@ -358,7 +521,9 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
<TickAxis range={range} />
|
||||
<div className="mt-1">
|
||||
<TickAxis range={range} />
|
||||
</div>
|
||||
{hover !== null && !drag && <BucketTooltip bucket={buckets[hover]} index={hover} />}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AGENT_DOT_COLORS,
|
||||
FIELD_COLS,
|
||||
FIELD_ROWS,
|
||||
UNNAMED_AGENT_COLOR,
|
||||
agoLabel,
|
||||
isReceiving,
|
||||
agentDotColor,
|
||||
columnDots,
|
||||
columnTop,
|
||||
litDots,
|
||||
} from "./lensField";
|
||||
|
||||
const capacity = FIELD_ROWS * FIELD_COLS;
|
||||
const bucket = (runs: number, failed = 0, agents: string[] = []) => ({ runs, failed, agents });
|
||||
const count = (dots: ReturnType<typeof columnDots>, kind: string) => dots.filter((dot) => dot.kind === kind).length;
|
||||
|
||||
describe("litDots", () => {
|
||||
it("lights nothing for an empty bucket and at least one full row for any runs", () => {
|
||||
expect(litDots(0, 10)).toBe(0);
|
||||
expect(litDots(1, 1000)).toBe(FIELD_COLS);
|
||||
});
|
||||
|
||||
it("fills the busiest bucket and keeps quieter buckets visible with square-root scaling", () => {
|
||||
expect(litDots(4, 4)).toBe(capacity);
|
||||
expect(litDots(1, 4)).toBe(capacity / 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("columnDots", () => {
|
||||
it("always returns one dot per slot in the column", () => {
|
||||
expect(columnDots(bucket(0), 4, 1)).toHaveLength(capacity);
|
||||
expect(count(columnDots(bucket(0), 4, 1), "grid")).toBe(capacity);
|
||||
});
|
||||
|
||||
it("puts failures above the successful runs", () => {
|
||||
const dots = columnDots(bucket(4, 1, ["a", "a", "a"]), 4, 3);
|
||||
const lastRun = dots.findLastIndex((dot) => dot.kind === "run");
|
||||
const firstFailed = dots.findIndex((dot) => dot.kind === "failed");
|
||||
expect(firstFailed).toBeGreaterThan(lastRun);
|
||||
expect(count(dots, "failed")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("colors successful runs by agent in bands sized by each agent's share", () => {
|
||||
const other = ["beta", "gamma", "delta", "omega", "zeta"].find(
|
||||
(name) => agentDotColor(name) !== agentDotColor("alpha"),
|
||||
);
|
||||
if (!other) throw new Error("palette maps every candidate to one color");
|
||||
const dots = columnDots(bucket(4, 0, ["alpha", "alpha", "alpha", other]), 4, 5);
|
||||
const colors = dots.flatMap((dot) => (dot.kind === "run" ? [dot.color] : []));
|
||||
const alpha = colors.filter((color) => color === agentDotColor("alpha")).length;
|
||||
const beta = colors.filter((color) => color === agentDotColor(other)).length;
|
||||
expect(alpha).toBeGreaterThan(beta * 2);
|
||||
expect(beta).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps the bottom row solid so even a single run reads as a mark", () => {
|
||||
const dots = columnDots(bucket(1, 0, ["a"]), 1000, 9);
|
||||
expect(dots.slice(0, FIELD_COLS).every((dot) => dot.kind === "run")).toBe(true);
|
||||
});
|
||||
|
||||
it("is deterministic for the same bucket and seed so the field does not flicker on re-render", () => {
|
||||
expect(columnDots(bucket(3, 1, ["a", "b"]), 4, 11)).toEqual(columnDots(bucket(3, 1, ["a", "b"]), 4, 11));
|
||||
});
|
||||
});
|
||||
|
||||
describe("agentDotColor", () => {
|
||||
it("gives each agent a stable color from the palette", () => {
|
||||
expect(agentDotColor("claude-code")).toBe(agentDotColor("claude-code"));
|
||||
expect(AGENT_DOT_COLORS).toContain(agentDotColor("claude-code"));
|
||||
});
|
||||
|
||||
it("keeps runs with no agent name neutral so they never look like a named agent", () => {
|
||||
expect(agentDotColor("")).toBe(UNNAMED_AGENT_COLOR);
|
||||
expect(AGENT_DOT_COLORS).not.toContain(agentDotColor(""));
|
||||
});
|
||||
});
|
||||
|
||||
describe("columnTop", () => {
|
||||
it("reaches the top of the field only for the busiest bucket", () => {
|
||||
expect(columnTop(4, 4)).toBe(1);
|
||||
expect(columnTop(0, 4)).toBe(0);
|
||||
expect(columnTop(1, 4)).toBeGreaterThan(0);
|
||||
expect(columnTop(1, 4)).toBeLessThan(columnTop(2, 4));
|
||||
});
|
||||
});
|
||||
|
||||
describe("agoLabel", () => {
|
||||
const now = Date.UTC(2026, 9, 3, 12, 0, 0);
|
||||
|
||||
it("rounds down to the largest whole unit", () => {
|
||||
expect(agoLabel(now - 2_000, now)).toBe("just now");
|
||||
expect(agoLabel(now - 45_000, now)).toBe("45s ago");
|
||||
expect(agoLabel(now - 12 * 60_000 - 59_000, now)).toBe("12m ago");
|
||||
expect(agoLabel(now - 3 * 3_600_000, now)).toBe("3h ago");
|
||||
expect(agoLabel(now - 2 * 86_400_000, now)).toBe("2d ago");
|
||||
});
|
||||
|
||||
it("treats a timestamp slightly in the future as just now instead of negative", () => {
|
||||
expect(agoLabel(now + 10_000, now)).toBe("just now");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReceiving", () => {
|
||||
const now = Date.UTC(2026, 9, 3, 12, 0, 0);
|
||||
|
||||
it("is live only when a trace landed in the last five minutes", () => {
|
||||
expect(isReceiving(now - 4 * 60_000, now)).toBe(true);
|
||||
expect(isReceiving(now - 6 * 60_000, now)).toBe(false);
|
||||
expect(isReceiving(null, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
export const AGENT_DOT_COLORS = ["#0011b3", "#3b5bfd", "#7c93ff", "#22b3e8"] as const;
|
||||
export const FIELD_ROWS = 12;
|
||||
export const FIELD_COLS = 2;
|
||||
|
||||
export type FieldDot = { kind: "run"; color: string } | { kind: "failed" } | { kind: "grid" };
|
||||
|
||||
export interface FieldBucket {
|
||||
runs: number;
|
||||
failed: number;
|
||||
agents: readonly string[];
|
||||
}
|
||||
|
||||
const hash = (seed: number, value: number): number => Math.imul(seed ^ Math.imul(value, 0x9e3779b1), 0x85ebca6b) >>> 0;
|
||||
|
||||
export const UNNAMED_AGENT_COLOR = "#94a3b8";
|
||||
|
||||
export function agentDotColor(agent: string): string {
|
||||
if (!agent) return UNNAMED_AGENT_COLOR;
|
||||
const code = Array.from(agent).reduce((total, char) => hash(total, char.charCodeAt(0)), 7);
|
||||
return AGENT_DOT_COLORS[code % AGENT_DOT_COLORS.length];
|
||||
}
|
||||
|
||||
/** Lit dot count for a bucket: square-root scaled against the busiest bucket so sparse traffic still reads. */
|
||||
export function litDots(runs: number, max: number, capacity = FIELD_ROWS * FIELD_COLS): number {
|
||||
if (runs === 0 || max === 0) return 0;
|
||||
return Math.max(FIELD_COLS, Math.round(Math.sqrt(runs / max) * capacity));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-up dots for one time bucket. Successful runs fill from the bottom in agent-colored bands sized by
|
||||
* each agent's share; failures sit on top. A stable sprinkle of gaps (seeded by bucket) keeps the field organic.
|
||||
*/
|
||||
export function columnDots(bucket: FieldBucket, max: number, seed: number): readonly FieldDot[] {
|
||||
const capacity = FIELD_ROWS * FIELD_COLS;
|
||||
const lit = litDots(bucket.runs, max, capacity);
|
||||
const failed =
|
||||
bucket.failed === 0 ? 0 : Math.min(lit, Math.max(FIELD_COLS, Math.round((bucket.failed / bucket.runs) * lit)));
|
||||
const ok = lit - failed;
|
||||
const agents = bucket.agents.length ? bucket.agents : [""];
|
||||
return Array.from({ length: capacity }, (_, index): FieldDot => {
|
||||
if (index >= lit) return { kind: "grid" };
|
||||
const gap = index >= FIELD_COLS && hash(seed + 1, index) % 100 < 12;
|
||||
if (gap) return { kind: "grid" };
|
||||
if (index >= ok) return { kind: "failed" };
|
||||
return { kind: "run", color: agentDotColor(agents[Math.floor((index / ok) * agents.length)]) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Top edge of a bucket's lit dots as a 0..1 fraction of the field height, for anchoring annotation leaders. */
|
||||
export function columnTop(runs: number, max: number): number {
|
||||
return Math.ceil(litDots(runs, max) / FIELD_COLS) / FIELD_ROWS;
|
||||
}
|
||||
|
||||
export function agoLabel(thenMs: number, nowMs: number): string {
|
||||
const seconds = Math.max(0, Math.floor((nowMs - thenMs) / 1000));
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
}
|
||||
|
||||
export const RECEIVING_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
export function isReceiving(lastMs: number | null, nowMs: number): boolean {
|
||||
return lastMs !== null && nowMs - lastMs < RECEIVING_WINDOW_MS;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue