mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
refactor(ui): extract shared timeline and time-range controls (#44584)
Move the timeline renderer to shared/timeline/Timeline taking buckets, a selected window, and callbacks as props, and TimeRangeControls to shared/timeline. Lens keeps bucketRuns as the adapter that converts loaded traces into buckets, preserving behavior without the histogram endpoint. Co-authored-by: Yujong Lee <yujong@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
fdc0e7dc2b
commit
21881c5711
10 changed files with 402 additions and 362 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <div className="pointer-events-none h-full flex-1" data-testid="timeline-bucket" data-runs={bucket.total} />;
|
||||
}
|
||||
|
||||
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
|
||||
className="pointer-events-none absolute top-full z-floating mt-1 rounded-md border border-border bg-popover px-2.5 py-1.5 font-mono text-xs text-popover-foreground shadow-md"
|
||||
style={{ left: pct(Math.min(0.8, index / BUCKETS)) }}
|
||||
role="tooltip"
|
||||
>
|
||||
<div>
|
||||
{moment(bucket.startMs).format(EDGE_FORMAT)} to {moment(bucket.endMs).format("HH:mm")}
|
||||
</div>
|
||||
<div>
|
||||
{bucket.total} {bucket.total === 1 ? "run" : "runs"}
|
||||
{bucket.failed > 0 && `, ${bucket.failed} failed`}
|
||||
</div>
|
||||
<div className="text-info">drag to zoom</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
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, tick, "-left-px before:left-0 after:left-0")}
|
||||
data-testid="timeline-handle-lo"
|
||||
onPointerDown={onHandleDown("resize-lo")}
|
||||
/>
|
||||
<span
|
||||
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-2 text-center font-mono text-xs font-semibold text-info">
|
||||
{formatSpan(window.endMs - window.startMs)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={cn(edgeLabel, leftFrac < 0.12 ? "" : "-translate-x-full pr-1")} style={{ left: pct(leftFrac) }}>
|
||||
{moment(window.startMs).format(format)}
|
||||
</span>
|
||||
<span className={cn(edgeLabel, rightFrac > 0.88 ? "-translate-x-full" : "pl-1")} style={{ left: pct(rightFrac) }}>
|
||||
{moment(window.endMs).format(format)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TickAxis({ range }: { range: TimeWindow }) {
|
||||
const format = tickFormat(range);
|
||||
const ticks = Array.from({ length: TICKS }, (_, i) => i / (TICKS - 1));
|
||||
return (
|
||||
<div className="relative mt-0.5 h-5">
|
||||
{ticks.map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className={cn(
|
||||
"absolute top-0 flex flex-col",
|
||||
t === 0 && "items-start",
|
||||
t === 1 && "items-end",
|
||||
t > 0 && t < 1 && "items-center",
|
||||
)}
|
||||
style={{ left: pct(t), transform: tickShift(t) }}
|
||||
>
|
||||
<span className="h-1 w-px bg-border" />
|
||||
<span className="font-mono text-xs whitespace-nowrap text-muted-foreground tabular-nums">
|
||||
{moment(range.startMs + (range.endMs - range.startMs) * t).format(format)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<number | null>(null);
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
const [draft, setDraft] = useState<Band | null>(null);
|
||||
const areaRef = useRef<HTMLDivElement>(null);
|
||||
const { width: stripWidth = 0 } = useResizeObserver({
|
||||
ref: areaRef as RefObject<HTMLDivElement>,
|
||||
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 (
|
||||
<div
|
||||
className="relative shrink-0 border-b border-border bg-card px-3 pt-2 pb-1 outline-none select-none"
|
||||
data-testid="traces-timeline"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<DotFieldRoot
|
||||
ref={areaRef}
|
||||
columns={buckets}
|
||||
band={band}
|
||||
hover={hover}
|
||||
role="presentation"
|
||||
data-testid="timeline-area"
|
||||
className="flex cursor-crosshair touch-none items-end"
|
||||
onPointerDown={(e) => begin("select", e)}
|
||||
onPointerMove={onMove}
|
||||
onPointerUp={onUp}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
onDoubleClick={() => onSelect(null)}
|
||||
>
|
||||
{hover !== null && !drag && (
|
||||
<div
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
<DotFieldCanvas />
|
||||
<NowEdge />
|
||||
{buckets.map((b) => (
|
||||
<BucketBar key={b.startMs} bucket={b} />
|
||||
))}
|
||||
{band && (
|
||||
<SelectionBracket
|
||||
band={band}
|
||||
window={windowOf(band)}
|
||||
format={labelFormat}
|
||||
stripWidth={stripWidth}
|
||||
onHandleDown={onHandleDown}
|
||||
/>
|
||||
)}
|
||||
</DotFieldRoot>
|
||||
<div className="mt-1">
|
||||
<TickAxis range={range} />
|
||||
</div>
|
||||
{hover !== null && !drag && <BucketTooltip bucket={buckets[hover]} index={hover} />}
|
||||
{selection && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(null)}
|
||||
aria-label="Clear time zoom"
|
||||
className="absolute top-1 right-3 inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <Timeline buckets={buckets} selection={selection} onSelect={onSelect} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
332
ui/litellm-dashboard/src/components/shared/timeline/Timeline.tsx
Normal file
332
ui/litellm-dashboard/src/components/shared/timeline/Timeline.tsx
Normal file
|
|
@ -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 <div className="pointer-events-none h-full flex-1" data-testid="timeline-bucket" data-runs={bucket.total} />;
|
||||
}
|
||||
|
||||
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, bucketCount }: { bucket: Bucket; index: number; bucketCount: number }) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute top-full z-floating mt-1 rounded-md border border-border bg-popover px-2.5 py-1.5 font-mono text-xs text-popover-foreground shadow-md"
|
||||
style={{ left: pct(Math.min(0.8, index / bucketCount)) }}
|
||||
role="tooltip"
|
||||
>
|
||||
<div>
|
||||
{moment(bucket.startMs).format(EDGE_FORMAT)} to {moment(bucket.endMs).format("HH:mm")}
|
||||
</div>
|
||||
<div>
|
||||
{bucket.total} {bucket.total === 1 ? "run" : "runs"}
|
||||
{bucket.failed > 0 && `, ${bucket.failed} failed`}
|
||||
</div>
|
||||
<div className="text-info">drag to zoom</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
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, tick, "-left-px before:left-0 after:left-0")}
|
||||
data-testid="timeline-handle-lo"
|
||||
onPointerDown={onHandleDown("resize-lo")}
|
||||
/>
|
||||
<span
|
||||
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-2 text-center font-mono text-xs font-semibold text-info">
|
||||
{formatSpan(window.endMs - window.startMs)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={cn(edgeLabel, leftFrac < 0.12 ? "" : "-translate-x-full pr-1")} style={{ left: pct(leftFrac) }}>
|
||||
{moment(window.startMs).format(format)}
|
||||
</span>
|
||||
<span className={cn(edgeLabel, rightFrac > 0.88 ? "-translate-x-full" : "pl-1")} style={{ left: pct(rightFrac) }}>
|
||||
{moment(window.endMs).format(format)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TickAxis({ range }: { range: TimeWindow }) {
|
||||
const format = tickFormat(range);
|
||||
const ticks = Array.from({ length: TICKS }, (_, i) => i / (TICKS - 1));
|
||||
return (
|
||||
<div className="relative mt-0.5 h-5">
|
||||
{ticks.map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className={cn(
|
||||
"absolute top-0 flex flex-col",
|
||||
t === 0 && "items-start",
|
||||
t === 1 && "items-end",
|
||||
t > 0 && t < 1 && "items-center",
|
||||
)}
|
||||
style={{ left: pct(t), transform: tickShift(t) }}
|
||||
>
|
||||
<span className="h-1 w-px bg-border" />
|
||||
<span className="font-mono text-xs whitespace-nowrap text-muted-foreground tabular-nums">
|
||||
{moment(range.startMs + (range.endMs - range.startMs) * t).format(format)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<number | null>(null);
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
const [draft, setDraft] = useState<Band | null>(null);
|
||||
const areaRef = useRef<HTMLDivElement>(null);
|
||||
const { width: stripWidth = 0 } = useResizeObserver({
|
||||
ref: areaRef as RefObject<HTMLDivElement>,
|
||||
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 (
|
||||
<div
|
||||
className="relative shrink-0 border-b border-border bg-card px-3 pt-2 pb-1 outline-none select-none"
|
||||
data-testid="traces-timeline"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<DotFieldRoot
|
||||
ref={areaRef}
|
||||
columns={buckets}
|
||||
band={band}
|
||||
hover={hover}
|
||||
role="presentation"
|
||||
data-testid="timeline-area"
|
||||
className="flex cursor-crosshair touch-none items-end"
|
||||
onPointerDown={(e) => begin("select", e)}
|
||||
onPointerMove={onMove}
|
||||
onPointerUp={onUp}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
onDoubleClick={() => onSelect(null)}
|
||||
>
|
||||
{hover !== null && !drag && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-[#0011b3]/50 dark:bg-[#8b9bff]/50"
|
||||
style={{ left: pct((hover + 0.5) / bucketCount) }}
|
||||
data-testid="timeline-cursor"
|
||||
/>
|
||||
)}
|
||||
<DotFieldCanvas />
|
||||
<NowEdge />
|
||||
{buckets.map((b) => (
|
||||
<BucketBar key={b.startMs} bucket={b} />
|
||||
))}
|
||||
{band && (
|
||||
<SelectionBracket
|
||||
band={band}
|
||||
window={windowOf(band)}
|
||||
format={labelFormat}
|
||||
stripWidth={stripWidth}
|
||||
bucketCount={bucketCount}
|
||||
onHandleDown={onHandleDown}
|
||||
/>
|
||||
)}
|
||||
</DotFieldRoot>
|
||||
<div className="mt-1">
|
||||
<TickAxis range={range} />
|
||||
</div>
|
||||
{hover !== null && !drag && <BucketTooltip bucket={buckets[hover]} index={hover} bucketCount={bucketCount} />}
|
||||
{selection && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(null)}
|
||||
aria-label="Clear time zoom"
|
||||
className="absolute top-1 right-3 inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue