diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx
index ffdb3a959a5..4f18584d9ce 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx
@@ -19,8 +19,9 @@ import {
} from "../routing";
import type { TraceSummary } from "../types";
import { RunView } from "../detail/run/RunView";
-import { TimeRangeControls } from "./TimeRangeControls";
-import { TracesTimeline, type TimeWindow } from "./TracesTimeline";
+import { TimeRangeControls } from "@/components/shared/timeline/TimeRangeControls";
+import { TracesTimeline } from "./TracesTimeline";
+import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import { TracingSetupCard } from "../../onboarding/tracing/TracingSetupCard";
import { useTracesLive } from "../api";
import { type AgentTracesResult, traceWindowStartMs, useAgentTraces, useTraceAvailability } from "./useAgentTraces";
diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.test.ts b/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.test.ts
index 4da827ae370..583f5886f5a 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.test.ts
+++ b/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { bandForWindow, bucketRuns, dragUpdate, formatSpan } from "./TracesTimeline";
+import { bucketRuns } from "./TracesTimeline";
import type { TraceSummary } from "../types";
const HOUR = 3600 * 1000;
@@ -41,43 +41,3 @@ describe("bucketRuns", () => {
expect(buckets[5]).toMatchObject({ total: 1, failed: 0 });
});
});
-
-describe("formatSpan", () => {
- it("prints the largest two units, dropping zero parts", () => {
- expect(formatSpan(45 * 60 * 1000)).toBe("45m");
- expect(formatSpan(6 * HOUR + 12 * 60 * 1000)).toBe("6h 12m");
- expect(formatSpan(24 * HOUR)).toBe("1d");
- expect(formatSpan(152 * 24 * HOUR + 23 * HOUR)).toBe("152d 23h");
- expect(formatSpan(-5)).toBe("0m");
- });
-});
-
-describe("dragUpdate", () => {
- const band = { lo: 10, hi: 14 };
-
- it("selects between the press point and the pointer, in either direction", () => {
- expect(dragUpdate({ mode: "select", origin: 20, band: { lo: 20, hi: 20 } }, 25)).toEqual({ lo: 20, hi: 25 });
- expect(dragUpdate({ mode: "select", origin: 20, band: { lo: 20, hi: 20 } }, 12)).toEqual({ lo: 12, hi: 20 });
- });
-
- it("resizes one edge without letting it cross the other", () => {
- expect(dragUpdate({ mode: "resize-lo", origin: 10, band }, 4)).toEqual({ lo: 4, hi: 14 });
- expect(dragUpdate({ mode: "resize-lo", origin: 10, band }, 30)).toEqual({ lo: 14, hi: 14 });
- expect(dragUpdate({ mode: "resize-hi", origin: 14, band }, 40)).toEqual({ lo: 10, hi: 40 });
- expect(dragUpdate({ mode: "resize-hi", origin: 14, band }, 2)).toEqual({ lo: 10, hi: 10 });
- });
-
- it("pans the band keeping its width, clamped to the strip", () => {
- expect(dragUpdate({ mode: "move", origin: 12, band }, 20)).toEqual({ lo: 18, hi: 22 });
- expect(dragUpdate({ mode: "move", origin: 12, band }, -50)).toEqual({ lo: 0, hi: 4 });
- expect(dragUpdate({ mode: "move", origin: 12, band }, 500, 60)).toEqual({ lo: 55, hi: 59 });
- });
-});
-
-describe("bandForWindow", () => {
- it("maps a selected window back to the buckets it covers", () => {
- const buckets = bucketRuns([], range, 10);
- expect(bandForWindow(buckets, { startMs: START + 2 * HOUR, endMs: START + 5 * HOUR })).toEqual({ lo: 2, hi: 4 });
- expect(bandForWindow(buckets, null)).toBeNull();
- });
-});
diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx
index 0cc580152fe..9cf947719d3 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx
@@ -1,36 +1,15 @@
"use client";
-import { X } from "lucide-react";
import moment from "moment";
-import { useMemo, useRef, useState, type RefObject } from "react";
-import { useResizeObserver } from "usehooks-ts";
+import { useMemo } from "react";
-import { DotFieldCanvas, DotFieldRoot } from "@/components/shared/dotField/DotField";
-import type { DotBand, DotColumn } from "@/components/shared/dotField/dots";
-import { cn } from "@/lib/cva.config";
+import { Timeline, TIMELINE_BUCKETS, type Bucket, type TimeWindow } from "@/components/shared/timeline/Timeline";
import type { TraceSummary } from "../types";
import { traceAgentNames } from "../utils";
-const BUCKETS = 60;
-const TICKS = 6;
-const MINUTE_MS = 60 * 1000;
-const HOUR_MS = 60 * MINUTE_MS;
-const DAY_MS = 24 * HOUR_MS;
-const EDGE_FORMAT = "MMM DD, HH:mm";
-
-export interface TimeWindow {
- startMs: number;
- endMs: number;
-}
-
-export interface Bucket extends DotColumn {
- startMs: number;
- endMs: number;
-}
-
/** Run counts per equal-width time bucket across the window; runs outside it are dropped. */
-export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buckets = BUCKETS): Bucket[] {
+export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buckets = TIMELINE_BUCKETS): Bucket[] {
const width = (range.endMs - range.startMs) / buckets;
const placed = runs.map((run) => ({
index: Math.floor((moment(run.start_time).valueOf() - range.startMs) / width),
@@ -49,28 +28,6 @@ export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buc
});
}
-/** Compact window length, Logfire-style: "45m", "6h 12m", "7d", "152d 23h". */
-export function formatSpan(ms: number): string {
- const totalMinutes = Math.max(0, Math.round(ms / MINUTE_MS));
- const days = Math.floor(totalMinutes / (24 * 60));
- const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
- const minutes = totalMinutes % 60;
- if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
- if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
- return `${minutes}m`;
-}
-
-const tickFormat = (range: TimeWindow): string => (range.endMs - range.startMs > 2 * DAY_MS ? EDGE_FORMAT : "HH:mm");
-
-const pct = (value: number): string => `${value * 100}%`;
-
-/** Keep the first / last tick label inside the strip; center the rest on their tick. */
-const tickShift = (t: number): string => {
- if (t === 0) return "translateX(0)";
- if (t === 1) return "translateX(-100%)";
- return "translateX(-50%)";
-};
-
interface TracesTimelineProps {
runs: readonly TraceSummary[];
range: TimeWindow;
@@ -78,273 +35,8 @@ interface TracesTimelineProps {
onSelect: (selection: TimeWindow | null) => void;
}
-function BucketBar({ bucket }: { bucket: Bucket }) {
- return
;
-}
-
-function NowEdge() {
- return (
- <>
-
-
-
-
-
- >
- );
-}
-
-function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
- return (
-
-
- {moment(bucket.startMs).format(EDGE_FORMAT)} to {moment(bucket.endMs).format("HH:mm")}
-
-
- {bucket.total} {bucket.total === 1 ? "run" : "runs"}
- {bucket.failed > 0 && `, ${bucket.failed} failed`}
-
-
drag to zoom
-
- );
-}
-
-const MIN_DURATION_LABEL_PX = 120;
-
-export type Band = DotBand;
-
-export type DragMode = "select" | "move" | "resize-lo" | "resize-hi";
-
-export interface DragState {
- mode: DragMode;
- origin: number;
- band: Band;
-}
-
-/** The band a drag produces when the pointer is over bucket `at`; always within [0, buckets - 1]. */
-export function dragUpdate(drag: DragState, at: number, buckets = BUCKETS): Band {
- const last = buckets - 1;
- const clamp = (i: number) => Math.min(last, Math.max(0, i));
- const { band, origin } = drag;
- if (drag.mode === "select") return { lo: Math.min(origin, clamp(at)), hi: Math.max(origin, clamp(at)) };
- if (drag.mode === "resize-lo") return { lo: Math.min(clamp(at), band.hi), hi: band.hi };
- if (drag.mode === "resize-hi") return { lo: band.lo, hi: Math.max(clamp(at), band.lo) };
- const width = band.hi - band.lo;
- const lo = Math.min(last - width, Math.max(0, band.lo + (at - origin)));
- return { lo, hi: lo + width };
-}
-
-/** Bucket band covered by a selected window, or null when nothing is selected. */
-export function bandForWindow(buckets: readonly Bucket[], selection: TimeWindow | null): Band | null {
- if (selection === null) return null;
- const inside = buckets.flatMap((b, i) => (b.startMs >= selection.startMs && b.endMs <= selection.endMs ? [i] : []));
- return inside.length > 0 ? { lo: inside[0], hi: inside[inside.length - 1] } : null;
-}
-
-const edgeFormat = (range: TimeWindow): string => (range.endMs - range.startMs < DAY_MS ? "HH:mm" : EDGE_FORMAT);
-
-/** The selected window drawn as a flat bracket: resize handles on each edge, times outside, span centered. */
-function SelectionBracket({
- band,
- window,
- format,
- stripWidth,
- onHandleDown,
-}: {
- band: Band;
- window: TimeWindow;
- format: string;
- stripWidth: number;
- onHandleDown: (mode: DragMode) => (e: React.PointerEvent) => void;
-}) {
- const leftFrac = band.lo / BUCKETS;
- const rightFrac = (band.hi + 1) / BUCKETS;
- const widthPx = (rightFrac - leftFrac) * stripWidth;
- 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 -bottom-5 font-mono text-xs whitespace-nowrap text-info tabular-nums";
- return (
- <>
-
-
-
- {widthPx >= MIN_DURATION_LABEL_PX && (
-
- {formatSpan(window.endMs - window.startMs)}
-
- )}
-
-
- {moment(window.startMs).format(format)}
-
- 0.88 ? "-translate-x-full" : "pl-1")} style={{ left: pct(rightFrac) }}>
- {moment(window.endMs).format(format)}
-
- >
- );
-}
-
-function TickAxis({ range }: { range: TimeWindow }) {
- const format = tickFormat(range);
- const ticks = Array.from({ length: TICKS }, (_, i) => i / (TICKS - 1));
- return (
-
- {ticks.map((t) => (
-
0 && t < 1 && "items-center",
- )}
- style={{ left: pct(t), transform: tickShift(t) }}
- >
-
-
- {moment(range.startMs + (range.endMs - range.startMs) * t).format(format)}
-
-
- ))}
-
- );
-}
-
-/** Histogram of runs over the window. Drag to select; drag the bracket or its edges to adjust; Esc clears. */
+/** Lens adapter: buckets the currently loaded runs and hands them to the shared timeline. */
export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimelineProps) {
const buckets = useMemo(() => bucketRuns(runs, range), [runs, range]);
- const [hover, setHover] = useState(null);
- const [drag, setDrag] = useState(null);
- const [draft, setDraft] = useState(null);
- const areaRef = useRef(null);
- const { width: stripWidth = 0 } = useResizeObserver({
- ref: areaRef as RefObject,
- box: "border-box",
- });
-
- const committed = bandForWindow(buckets, selection);
- const band = drag ? draft : committed;
- const windowOf = (b: Band): TimeWindow => ({ startMs: buckets[b.lo].startMs, endMs: buckets[b.hi].endMs });
-
- const indexAt = (clientX: number): number => {
- const rect = areaRef.current?.getBoundingClientRect();
- if (!rect || rect.width === 0) return 0;
- return Math.min(BUCKETS - 1, Math.max(0, Math.floor(((clientX - rect.left) / rect.width) * BUCKETS)));
- };
- const begin = (mode: DragMode, e: React.PointerEvent) => {
- e.preventDefault();
- e.stopPropagation();
- areaRef.current?.setPointerCapture?.(e.pointerId);
- const at = indexAt(e.clientX);
- const start = mode === "select" || committed === null ? { lo: at, hi: at } : committed;
- setDrag({ mode: committed === null ? "select" : mode, origin: at, band: start });
- setDraft(start);
- };
- const onHandleDown = (mode: DragMode) => (e: React.PointerEvent) => begin(mode, e);
- const onMove = (e: React.PointerEvent) => {
- const at = indexAt(e.clientX);
- setHover(at);
- if (drag) setDraft(dragUpdate(drag, at));
- };
- const onUp = (e: React.PointerEvent) => {
- areaRef.current?.releasePointerCapture?.(e.pointerId);
- if (!drag || !draft) return;
- const finished = draft;
- const wasSelect = drag.mode === "select";
- setDrag(null);
- setDraft(null);
- if (wasSelect && finished.lo === finished.hi) {
- onSelect(null);
- return;
- }
- onSelect(windowOf(finished));
- };
- const onKeyDown = (e: React.KeyboardEvent) => {
- if (e.key !== "Escape" || selection === null) return;
- e.preventDefault();
- onSelect(null);
- };
-
- const labelFormat = edgeFormat(range);
-
- return (
-
-
begin("select", e)}
- onPointerMove={onMove}
- onPointerUp={onUp}
- onPointerLeave={() => setHover(null)}
- onDoubleClick={() => onSelect(null)}
- >
- {hover !== null && !drag && (
-
- )}
-
-
- {buckets.map((b) => (
-
- ))}
- {band && (
-
- )}
-
-
-
-
- {hover !== null && !drag &&
}
- {selection && (
-
- )}
-
- );
+ return ;
}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/RunSearch.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/RunSearch.tsx
index 16faaaca7ff..82a35eb0fca 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/RunSearch.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/RunSearch.tsx
@@ -3,7 +3,7 @@
import { useMemo } from "react";
import type { TraceSummary } from "../../types";
-import type { TimeWindow } from "../TracesTimeline";
+import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import { SearchBox } from "@/components/shared/search/SearchBox";
import { itemValues } from "@/components/shared/search/valueSource";
diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/RunsToolbar.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/RunsToolbar.tsx
index f7852fa98a1..4cc6594369b 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/RunsToolbar.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/RunsToolbar.tsx
@@ -1,7 +1,7 @@
"use client";
import type { TraceSummary } from "../../types";
-import type { TimeWindow } from "../TracesTimeline";
+import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import { RunSearch } from "./RunSearch";
diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.ts b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.ts
index 9a3751593bd..96b5ae6455c 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.ts
+++ b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.ts
@@ -1,5 +1,5 @@
import { getProxyBaseUrl } from "@/components/networking";
-import type { TimeWindow } from "../TracesTimeline";
+import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import { valueMatcher } from "@/components/shared/search/language";
import type { SearchFilter, SearchQuery } from "@/components/shared/search/searchQuery";
diff --git a/ui/litellm-dashboard/src/components/lens/traces/routing.ts b/ui/litellm-dashboard/src/components/lens/traces/routing.ts
index 744be6bba2b..1e4ecccd218 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/routing.ts
+++ b/ui/litellm-dashboard/src/components/lens/traces/routing.ts
@@ -9,8 +9,8 @@ import {
} from "nuqs";
import { useCallback, useState } from "react";
-import { RANGE_PRESETS } from "./list/TimeRangeControls";
-import type { TimeWindow } from "./list/TracesTimeline";
+import { RANGE_PRESETS } from "@/components/shared/timeline/TimeRangeControls";
+import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import type { TraceSummary } from "./types";
export const TRACE_VIEWS = ["steps", "conversation"] as const;
diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/TimeRangeControls.tsx b/ui/litellm-dashboard/src/components/shared/timeline/TimeRangeControls.tsx
similarity index 98%
rename from ui/litellm-dashboard/src/components/lens/traces/list/TimeRangeControls.tsx
rename to ui/litellm-dashboard/src/components/shared/timeline/TimeRangeControls.tsx
index c472a55b0a8..adbbd099b19 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/list/TimeRangeControls.tsx
+++ b/ui/litellm-dashboard/src/components/shared/timeline/TimeRangeControls.tsx
@@ -12,7 +12,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
-import type { TimeWindow } from "./TracesTimeline";
+import type { TimeWindow } from "./Timeline";
export const RANGE_PRESETS = [
{ hours: 1, label: "Last hour" },
diff --git a/ui/litellm-dashboard/src/components/shared/timeline/Timeline.test.ts b/ui/litellm-dashboard/src/components/shared/timeline/Timeline.test.ts
new file mode 100644
index 00000000000..e0943bbbc85
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/shared/timeline/Timeline.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+
+import { bandForWindow, dragUpdate, formatSpan, type Bucket } from "./Timeline";
+
+const HOUR = 3600 * 1000;
+const START = Date.UTC(2026, 8, 30, 0, 0, 0);
+
+const emptyBuckets = (count: number): Bucket[] =>
+ Array.from({ length: count }, (_, i) => ({
+ startMs: START + i * HOUR,
+ endMs: START + (i + 1) * HOUR,
+ total: 0,
+ failed: 0,
+ series: [],
+ }));
+
+describe("formatSpan", () => {
+ it("prints the largest two units, dropping zero parts", () => {
+ expect(formatSpan(45 * 60 * 1000)).toBe("45m");
+ expect(formatSpan(6 * HOUR + 12 * 60 * 1000)).toBe("6h 12m");
+ expect(formatSpan(24 * HOUR)).toBe("1d");
+ expect(formatSpan(152 * 24 * HOUR + 23 * HOUR)).toBe("152d 23h");
+ expect(formatSpan(-5)).toBe("0m");
+ });
+});
+
+describe("dragUpdate", () => {
+ const band = { lo: 10, hi: 14 };
+
+ it("selects between the press point and the pointer, in either direction", () => {
+ expect(dragUpdate({ mode: "select", origin: 20, band: { lo: 20, hi: 20 } }, 25)).toEqual({ lo: 20, hi: 25 });
+ expect(dragUpdate({ mode: "select", origin: 20, band: { lo: 20, hi: 20 } }, 12)).toEqual({ lo: 12, hi: 20 });
+ });
+
+ it("resizes one edge without letting it cross the other", () => {
+ expect(dragUpdate({ mode: "resize-lo", origin: 10, band }, 4)).toEqual({ lo: 4, hi: 14 });
+ expect(dragUpdate({ mode: "resize-lo", origin: 10, band }, 30)).toEqual({ lo: 14, hi: 14 });
+ expect(dragUpdate({ mode: "resize-hi", origin: 14, band }, 40)).toEqual({ lo: 10, hi: 40 });
+ expect(dragUpdate({ mode: "resize-hi", origin: 14, band }, 2)).toEqual({ lo: 10, hi: 10 });
+ });
+
+ it("pans the band keeping its width, clamped to the strip", () => {
+ expect(dragUpdate({ mode: "move", origin: 12, band }, 20)).toEqual({ lo: 18, hi: 22 });
+ expect(dragUpdate({ mode: "move", origin: 12, band }, -50)).toEqual({ lo: 0, hi: 4 });
+ expect(dragUpdate({ mode: "move", origin: 12, band }, 500, 60)).toEqual({ lo: 55, hi: 59 });
+ });
+});
+
+describe("bandForWindow", () => {
+ it("maps a selected window back to the buckets it covers", () => {
+ const buckets = emptyBuckets(10);
+ expect(bandForWindow(buckets, { startMs: START + 2 * HOUR, endMs: START + 5 * HOUR })).toEqual({ lo: 2, hi: 4 });
+ expect(bandForWindow(buckets, null)).toBeNull();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/shared/timeline/Timeline.tsx b/ui/litellm-dashboard/src/components/shared/timeline/Timeline.tsx
new file mode 100644
index 00000000000..faac4802238
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/shared/timeline/Timeline.tsx
@@ -0,0 +1,332 @@
+"use client";
+
+import { X } from "lucide-react";
+import moment from "moment";
+import { useRef, useState, type RefObject } from "react";
+import { useResizeObserver } from "usehooks-ts";
+
+import { DotFieldCanvas, DotFieldRoot } from "@/components/shared/dotField/DotField";
+import type { DotBand, DotColumn } from "@/components/shared/dotField/dots";
+import { cn } from "@/lib/cva.config";
+
+export const TIMELINE_BUCKETS = 60;
+const TICKS = 6;
+const MINUTE_MS = 60 * 1000;
+const HOUR_MS = 60 * MINUTE_MS;
+const DAY_MS = 24 * HOUR_MS;
+const EDGE_FORMAT = "MMM DD, HH:mm";
+
+export interface TimeWindow {
+ startMs: number;
+ endMs: number;
+}
+
+export interface Bucket extends DotColumn {
+ startMs: number;
+ endMs: number;
+}
+
+/** Compact window length, Logfire-style: "45m", "6h 12m", "7d", "152d 23h". */
+export function formatSpan(ms: number): string {
+ const totalMinutes = Math.max(0, Math.round(ms / MINUTE_MS));
+ const days = Math.floor(totalMinutes / (24 * 60));
+ const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
+ const minutes = totalMinutes % 60;
+ if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
+ if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
+ return `${minutes}m`;
+}
+
+const tickFormat = (range: TimeWindow): string => (range.endMs - range.startMs > 2 * DAY_MS ? EDGE_FORMAT : "HH:mm");
+
+const pct = (value: number): string => `${value * 100}%`;
+
+/** Keep the first / last tick label inside the strip; center the rest on their tick. */
+const tickShift = (t: number): string => {
+ if (t === 0) return "translateX(0)";
+ if (t === 1) return "translateX(-100%)";
+ return "translateX(-50%)";
+};
+
+interface TimelineProps {
+ buckets: readonly Bucket[];
+ selection: TimeWindow | null;
+ onSelect: (selection: TimeWindow | null) => void;
+}
+
+function BucketBar({ bucket }: { bucket: Bucket }) {
+ return ;
+}
+
+function NowEdge() {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
+
+function BucketTooltip({ bucket, index, bucketCount }: { bucket: Bucket; index: number; bucketCount: number }) {
+ return (
+
+
+ {moment(bucket.startMs).format(EDGE_FORMAT)} to {moment(bucket.endMs).format("HH:mm")}
+
+
+ {bucket.total} {bucket.total === 1 ? "run" : "runs"}
+ {bucket.failed > 0 && `, ${bucket.failed} failed`}
+
+
drag to zoom
+
+ );
+}
+
+const MIN_DURATION_LABEL_PX = 120;
+
+export type Band = DotBand;
+
+export type DragMode = "select" | "move" | "resize-lo" | "resize-hi";
+
+export interface DragState {
+ mode: DragMode;
+ origin: number;
+ band: Band;
+}
+
+/** The band a drag produces when the pointer is over bucket `at`; always within [0, buckets - 1]. */
+export function dragUpdate(drag: DragState, at: number, buckets = TIMELINE_BUCKETS): Band {
+ const last = buckets - 1;
+ const clamp = (i: number) => Math.min(last, Math.max(0, i));
+ const { band, origin } = drag;
+ if (drag.mode === "select") return { lo: Math.min(origin, clamp(at)), hi: Math.max(origin, clamp(at)) };
+ if (drag.mode === "resize-lo") return { lo: Math.min(clamp(at), band.hi), hi: band.hi };
+ if (drag.mode === "resize-hi") return { lo: band.lo, hi: Math.max(clamp(at), band.lo) };
+ const width = band.hi - band.lo;
+ const lo = Math.min(last - width, Math.max(0, band.lo + (at - origin)));
+ return { lo, hi: lo + width };
+}
+
+/** Bucket band covered by a selected window, or null when nothing is selected. */
+export function bandForWindow(buckets: readonly Bucket[], selection: TimeWindow | null): Band | null {
+ if (selection === null) return null;
+ const inside = buckets.flatMap((b, i) => (b.startMs >= selection.startMs && b.endMs <= selection.endMs ? [i] : []));
+ return inside.length > 0 ? { lo: inside[0], hi: inside[inside.length - 1] } : null;
+}
+
+const edgeFormat = (range: TimeWindow): string => (range.endMs - range.startMs < DAY_MS ? "HH:mm" : EDGE_FORMAT);
+
+/** The selected window drawn as a flat bracket: resize handles on each edge, times outside, span centered. */
+function SelectionBracket({
+ band,
+ window,
+ format,
+ stripWidth,
+ bucketCount,
+ onHandleDown,
+}: {
+ band: Band;
+ window: TimeWindow;
+ format: string;
+ stripWidth: number;
+ bucketCount: number;
+ onHandleDown: (mode: DragMode) => (e: React.PointerEvent) => void;
+}) {
+ const leftFrac = band.lo / bucketCount;
+ const rightFrac = (band.hi + 1) / bucketCount;
+ const widthPx = (rightFrac - leftFrac) * stripWidth;
+ 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 -bottom-5 font-mono text-xs whitespace-nowrap text-info tabular-nums";
+ return (
+ <>
+
+
+
+ {widthPx >= MIN_DURATION_LABEL_PX && (
+
+ {formatSpan(window.endMs - window.startMs)}
+
+ )}
+
+
+ {moment(window.startMs).format(format)}
+
+ 0.88 ? "-translate-x-full" : "pl-1")} style={{ left: pct(rightFrac) }}>
+ {moment(window.endMs).format(format)}
+
+ >
+ );
+}
+
+function TickAxis({ range }: { range: TimeWindow }) {
+ const format = tickFormat(range);
+ const ticks = Array.from({ length: TICKS }, (_, i) => i / (TICKS - 1));
+ return (
+
+ {ticks.map((t) => (
+
0 && t < 1 && "items-center",
+ )}
+ style={{ left: pct(t), transform: tickShift(t) }}
+ >
+
+
+ {moment(range.startMs + (range.endMs - range.startMs) * t).format(format)}
+
+
+ ))}
+
+ );
+}
+
+/** Histogram over the window covered by `buckets`. Drag to select; drag the bracket or its edges to adjust; Esc clears. */
+export function Timeline({ buckets, selection, onSelect }: TimelineProps) {
+ const bucketCount = buckets.length;
+ const [hover, setHover] = useState(null);
+ const [drag, setDrag] = useState(null);
+ const [draft, setDraft] = useState(null);
+ const areaRef = useRef(null);
+ const { width: stripWidth = 0 } = useResizeObserver({
+ ref: areaRef as RefObject,
+ box: "border-box",
+ });
+
+ const committed = bandForWindow(buckets, selection);
+ const band = drag ? draft : committed;
+ const windowOf = (b: Band): TimeWindow => ({ startMs: buckets[b.lo].startMs, endMs: buckets[b.hi].endMs });
+
+ const indexAt = (clientX: number): number => {
+ const rect = areaRef.current?.getBoundingClientRect();
+ if (!rect || rect.width === 0) return 0;
+ return Math.min(bucketCount - 1, Math.max(0, Math.floor(((clientX - rect.left) / rect.width) * bucketCount)));
+ };
+ const begin = (mode: DragMode, e: React.PointerEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ areaRef.current?.setPointerCapture?.(e.pointerId);
+ const at = indexAt(e.clientX);
+ const start = mode === "select" || committed === null ? { lo: at, hi: at } : committed;
+ setDrag({ mode: committed === null ? "select" : mode, origin: at, band: start });
+ setDraft(start);
+ };
+ const onHandleDown = (mode: DragMode) => (e: React.PointerEvent) => begin(mode, e);
+ const onMove = (e: React.PointerEvent) => {
+ const at = indexAt(e.clientX);
+ setHover(at);
+ if (drag) setDraft(dragUpdate(drag, at, bucketCount));
+ };
+ const onUp = (e: React.PointerEvent) => {
+ areaRef.current?.releasePointerCapture?.(e.pointerId);
+ if (!drag || !draft) return;
+ const finished = draft;
+ const wasSelect = drag.mode === "select";
+ setDrag(null);
+ setDraft(null);
+ if (wasSelect && finished.lo === finished.hi) {
+ onSelect(null);
+ return;
+ }
+ onSelect(windowOf(finished));
+ };
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key !== "Escape" || selection === null) return;
+ e.preventDefault();
+ onSelect(null);
+ };
+
+ if (bucketCount === 0) return null;
+
+ const range: TimeWindow = { startMs: buckets[0].startMs, endMs: buckets[bucketCount - 1].endMs };
+ const labelFormat = edgeFormat(range);
+
+ return (
+
+
begin("select", e)}
+ onPointerMove={onMove}
+ onPointerUp={onUp}
+ onPointerLeave={() => setHover(null)}
+ onDoubleClick={() => onSelect(null)}
+ >
+ {hover !== null && !drag && (
+
+ )}
+
+
+ {buckets.map((b) => (
+
+ ))}
+ {band && (
+
+ )}
+
+
+
+
+ {hover !== null && !drag &&
}
+ {selection && (
+
+ )}
+
+ );
+}