{!text && (
diff --git a/ui/litellm-dashboard/src/components/shared/timeline/TimeRangeControls.tsx b/ui/litellm-dashboard/src/components/shared/timeRange/TimeRangeControls.tsx
similarity index 65%
rename from ui/litellm-dashboard/src/components/shared/timeline/TimeRangeControls.tsx
rename to ui/litellm-dashboard/src/components/shared/timeRange/TimeRangeControls.tsx
index dd560a9f6ec..19cad1e7ea8 100644
--- a/ui/litellm-dashboard/src/components/shared/timeline/TimeRangeControls.tsx
+++ b/ui/litellm-dashboard/src/components/shared/timeRange/TimeRangeControls.tsx
@@ -12,61 +12,55 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
-import type { TimeWindow } from "./Timeline";
-
-export const RANGE_PRESETS = [
- { hours: 1, label: "Last hour" },
- { hours: 6, label: "Last 6 hours" },
- { hours: 24, label: "Last 24 hours" },
- { hours: 168, label: "Last 7 days" },
- { hours: 720, label: "Last 30 days" },
-] as const;
+import { isLive, presetLabel, RANGE_PRESETS, type RelativeRange, type TimeWindow, timeWindow } from "./timeRange";
const RANGE_LABEL_FORMAT = "MMM D, h:mm A";
const fixedRangeLabel = (range: TimeWindow): string =>
`${moment(range.startMs).format(RANGE_LABEL_FORMAT)} to ${moment(range.endMs).format(RANGE_LABEL_FORMAT)}`;
-const presetLabel = (hours: number): string =>
- RANGE_PRESETS.find((preset) => preset.hours === hours)?.label ?? `Last ${hours} hours`;
+/** A zoom or a paused range names its actual bounds; a live range names its preset. */
+const rangeLabel = (range: RelativeRange, zoom: TimeWindow | null): string => {
+ if (zoom) return fixedRangeLabel(zoom);
+ return range.anchorMs === null ? presetLabel(range.hours) : fixedRangeLabel(timeWindow(range, range.anchorMs));
+};
const SEGMENT = "inline-flex h-full items-center gap-1.5 px-3 text-sm outline-none focus-visible:bg-accent";
interface TimeRangeControlsProps {
- fixedRange: TimeWindow | null;
- rangeHours: number;
- onRangeHoursChange: (hours: number) => void;
- live: boolean;
+ range: RelativeRange;
+ zoom: TimeWindow | null;
+ onHoursChange: (hours: number) => void;
showLive?: boolean;
onLiveChange: (live: boolean) => void;
}
/** Joined control group: the time range (opens presets) and Live. */
export function TimeRangeControls({
- fixedRange,
- rangeHours,
- onRangeHoursChange,
- live,
+ range,
+ zoom,
+ onHoursChange,
showLive = true,
onLiveChange,
}: TimeRangeControlsProps) {
- const label = fixedRange ? fixedRangeLabel(fixedRange) : presetLabel(rangeHours);
+ const live = isLive(range);
+ const label = rangeLabel(range, zoom);
return (
-
+
{label}
-
+
onRangeHoursChange(Number(value))}
+ value={String(range.hours)}
+ onValueChange={(value: string) => onHoursChange(Number(value))}
>
{RANGE_PRESETS.map((preset) => (
@@ -83,7 +77,7 @@ export function TimeRangeControls({
onClick={() => onLiveChange(!live)}
className={cn(
SEGMENT,
- "shrink-0",
+ "shrink-0 border-l border-border",
live ? "bg-info/10 text-info hover:bg-info/15" : "text-muted-foreground hover:text-foreground",
)}
>
diff --git a/ui/litellm-dashboard/src/components/shared/timeRange/routing.ts b/ui/litellm-dashboard/src/components/shared/timeRange/routing.ts
new file mode 100644
index 00000000000..f4e9e0f0995
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/shared/timeRange/routing.ts
@@ -0,0 +1,26 @@
+import { parseAsInteger, parseAsNumberLiteral, useQueryState, useQueryStates } from "nuqs";
+import { useCallback } from "react";
+
+import { DEFAULT_RANGE_HOURS, isRangeHours, RANGE_HOURS, type TimeWindow } from "./timeRange";
+
+export const TIME_RANGE_PARSERS = {
+ hours: parseAsNumberLiteral(RANGE_HOURS).withDefault(DEFAULT_RANGE_HOURS),
+ from: parseAsInteger,
+ to: parseAsInteger,
+};
+
+export function useRangeHoursRouting(): [number, (hours: number) => void] {
+ const [hours, setHours] = useQueryState("hours", TIME_RANGE_PARSERS.hours);
+ const setRangeHours = useCallback((next: number) => void (isRangeHours(next) && setHours(next)), [setHours]);
+ return [hours, setRangeHours];
+}
+
+/** A timeline brush narrows the list to a window inside the range; it is dropped whenever the range changes. */
+export function useZoomRouting(): [TimeWindow | null, (zoom: TimeWindow | null) => void] {
+ const [{ from, to }, setParams] = useQueryStates({ from: TIME_RANGE_PARSERS.from, to: TIME_RANGE_PARSERS.to });
+ const setZoom = useCallback(
+ (zoom: TimeWindow | null) => void setParams({ from: zoom?.startMs ?? null, to: zoom?.endMs ?? null }),
+ [setParams],
+ );
+ return [from !== null && to !== null && from < to ? { startMs: from, endMs: to } : null, setZoom];
+}
diff --git a/ui/litellm-dashboard/src/components/shared/timeRange/timeRange.test.ts b/ui/litellm-dashboard/src/components/shared/timeRange/timeRange.test.ts
new file mode 100644
index 00000000000..a3e15f7f9a6
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/shared/timeRange/timeRange.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+
+import { isRangeHours, presetLabel, timeWindow } from "./timeRange";
+
+const HOUR = 3600 * 1000;
+
+describe("timeWindow", () => {
+ it("rolls a live range forward with now so live tail keeps a fixed-length window", () => {
+ const mountedAt = Date.parse("2026-09-30T10:00:00");
+ const tenHoursLater = mountedAt + 10 * HOUR;
+ const live = { hours: 24, anchorMs: null };
+ expect(timeWindow(live, tenHoursLater)).toEqual({ startMs: tenHoursLater - 24 * HOUR, endMs: tenHoursLater });
+ expect(timeWindow(live, tenHoursLater).startMs).toBeGreaterThan(timeWindow(live, mountedAt).startMs);
+ });
+
+ it("keeps a paused range pinned to its anchor whatever now is", () => {
+ const anchorMs = Date.parse("2026-09-02T00:00");
+ expect(timeWindow({ hours: 24, anchorMs }, Date.parse("2026-09-30T00:00:00"))).toEqual({
+ startMs: anchorMs - 24 * HOUR,
+ endMs: anchorMs,
+ });
+ });
+});
+
+describe("presets", () => {
+ it("accepts only the preset lengths from the URL and names them", () => {
+ expect(isRangeHours(168)).toBe(true);
+ expect(isRangeHours(2)).toBe(false);
+ expect(presetLabel(168)).toBe("Last 7 days");
+ expect(presetLabel(2)).toBe("Last 2 hours");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/shared/timeRange/timeRange.ts b/ui/litellm-dashboard/src/components/shared/timeRange/timeRange.ts
new file mode 100644
index 00000000000..835e00f9b96
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/shared/timeRange/timeRange.ts
@@ -0,0 +1,38 @@
+const HOUR_MS = 60 * 60 * 1000;
+
+export interface TimeWindow {
+ readonly startMs: number;
+ readonly endMs: number;
+}
+
+export const RANGE_PRESETS = [
+ { hours: 1, label: "Last hour" },
+ { hours: 6, label: "Last 6 hours" },
+ { hours: 24, label: "Last 24 hours" },
+ { hours: 168, label: "Last 7 days" },
+ { hours: 720, label: "Last 30 days" },
+] as const;
+
+export type RangeHours = (typeof RANGE_PRESETS)[number]["hours"];
+export const RANGE_HOURS: readonly RangeHours[] = RANGE_PRESETS.map((preset) => preset.hours);
+export const DEFAULT_RANGE_HOURS: RangeHours = 24;
+export const isRangeHours = (hours: number): hours is RangeHours => RANGE_HOURS.includes(hours as RangeHours);
+
+export const presetLabel = (hours: number): string =>
+ RANGE_PRESETS.find((preset) => preset.hours === hours)?.label ?? `Last ${hours} hours`;
+
+/** A preset length that ends now while live, or at the moment live was paused. */
+export interface RelativeRange {
+ readonly hours: number;
+ readonly anchorMs: number | null;
+}
+
+export const isLive = (range: RelativeRange): boolean => range.anchorMs === null;
+
+/** The window a range covers; a live range rolls with `nowMs`, a paused one ignores it. */
+export const timeWindow = (range: RelativeRange, nowMs: number): TimeWindow => {
+ const endMs = range.anchorMs ?? nowMs;
+ return { startMs: endMs - range.hours * HOUR_MS, endMs };
+};
+
+export const LIVE_TAIL_INTERVAL_MS = 15000;
diff --git a/ui/litellm-dashboard/src/components/shared/timeRange/useRelativeRange.ts b/ui/litellm-dashboard/src/components/shared/timeRange/useRelativeRange.ts
new file mode 100644
index 00000000000..7c7c742d69e
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/shared/timeRange/useRelativeRange.ts
@@ -0,0 +1,30 @@
+import { useMemo, useState } from "react";
+
+import { useRangeHoursRouting } from "./routing";
+import type { RelativeRange } from "./timeRange";
+
+export interface RelativeRangeState {
+ readonly range: RelativeRange;
+ readonly setHours: (hours: number) => void;
+ readonly setLive: (live: boolean) => void;
+}
+
+/** The preset comes from the URL; live is page state. Any change re-anchors a paused range to now. */
+export function useRelativeRange(canGoLive: boolean): RelativeRangeState {
+ const [hours, setRangeHours] = useRangeHoursRouting();
+ const [live, setLiveState] = useState(true);
+ const [anchorMs, setAnchorMs] = useState(() => Date.now());
+ const rolling = live && canGoLive;
+ const range = useMemo(() => ({ hours, anchorMs: rolling ? null : anchorMs }), [hours, rolling, anchorMs]);
+ return {
+ range,
+ setHours: (next) => {
+ setRangeHours(next);
+ setAnchorMs(Date.now());
+ },
+ setLive: (next) => {
+ setLiveState(next);
+ setAnchorMs(Date.now());
+ },
+ };
+}
diff --git a/ui/litellm-dashboard/src/components/shared/timeline/Timeline.test.ts b/ui/litellm-dashboard/src/components/shared/timeline/Timeline.test.ts
index a7c92e8c1cb..819b1cc793d 100644
--- a/ui/litellm-dashboard/src/components/shared/timeline/Timeline.test.ts
+++ b/ui/litellm-dashboard/src/components/shared/timeline/Timeline.test.ts
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
-import { bandForWindow, dragUpdate, formatSpan, timelineTicks, type Bucket } from "./Timeline";
+import { bandForWindow, dragUpdate, formatSpan, timelineTicks, type TimeBucket } from "./Timeline";
const HOUR = 3600 * 1000;
const START = Date.UTC(2026, 8, 30, 0, 0, 0);
-const emptyBuckets = (count: number): Bucket[] =>
+const emptyBuckets = (count: number): TimeBucket[] =>
Array.from({ length: count }, (_, i) => ({
startMs: START + i * HOUR,
endMs: START + (i + 1) * HOUR,
diff --git a/ui/litellm-dashboard/src/components/shared/timeline/Timeline.tsx b/ui/litellm-dashboard/src/components/shared/timeline/Timeline.tsx
index dc4b4fba76f..b4f051baa68 100644
--- a/ui/litellm-dashboard/src/components/shared/timeline/Timeline.tsx
+++ b/ui/litellm-dashboard/src/components/shared/timeline/Timeline.tsx
@@ -9,6 +9,8 @@ import { DotFieldCanvas, DotFieldRoot } from "@/components/shared/dotField/DotFi
import type { DotBand, DotColumn } from "@/components/shared/dotField/dots";
import { cn } from "@/lib/cva.config";
+import type { TimeWindow } from "../timeRange/timeRange";
+
export const TIMELINE_BUCKETS = 60;
const TICKS = 6;
const MINUTE_MS = 60 * 1000;
@@ -16,15 +18,12 @@ const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
const EDGE_FORMAT = "MMM DD, HH:mm";
-export interface TimeWindow {
+export interface TimeBucket extends DotColumn {
startMs: number;
endMs: number;
}
-export interface Bucket extends DotColumn {
- startMs: number;
- endMs: number;
-}
+export type ItemNoun = { readonly singular: string; readonly plural: string };
/** Compact window length, Logfire-style: "45m", "6h 12m", "7d", "152d 23h". */
export function formatSpan(ms: number): string {
@@ -49,13 +48,15 @@ const tickShift = (t: number): string => {
};
interface TimelineProps {
- buckets: readonly Bucket[];
+ buckets: readonly TimeBucket[];
selection: TimeWindow | null;
onSelect: (selection: TimeWindow | null) => void;
+ /** What one counted item is called in the hover tooltip. */
+ noun: ItemNoun;
}
-function BucketBar({ bucket }: { bucket: Bucket }) {
- return ;
+function BucketBar({ bucket }: { bucket: TimeBucket }) {
+ return ;
}
function NowEdge() {
@@ -63,7 +64,7 @@ function NowEdge() {
<>
@@ -73,7 +74,17 @@ function NowEdge() {
);
}
-function BucketTooltip({ bucket, index, bucketCount }: { bucket: Bucket; index: number; bucketCount: number }) {
+function BucketTooltip({
+ bucket,
+ index,
+ bucketCount,
+ noun,
+}: {
+ bucket: TimeBucket;
+ index: number;
+ bucketCount: number;
+ noun: ItemNoun;
+}) {
return (
- {bucket.total} {bucket.total === 1 ? "run" : "runs"}
+ {bucket.total} {bucket.total === 1 ? noun.singular : noun.plural}
{bucket.failed > 0 && `, ${bucket.failed} failed`}
drag to zoom
@@ -118,7 +129,7 @@ export function dragUpdate(drag: DragState, at: number, buckets = TIMELINE_BUCKE
}
/** Bucket band covered by a selected window, or null when nothing is selected. */
-export function bandForWindow(buckets: readonly Bucket[], selection: TimeWindow | null): Band | null {
+export function bandForWindow(buckets: readonly TimeBucket[], 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;
@@ -216,7 +227,7 @@ function TickAxis({ range, width }: { range: TimeWindow; width: number }) {
}
/** 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) {
+export function Timeline({ buckets, selection, onSelect, noun }: TimelineProps) {
const bucketCount = buckets.length;
const [hover, setHover] = useState
(null);
const [drag, setDrag] = useState(null);
@@ -278,7 +289,7 @@ export function Timeline({ buckets, selection, onSelect }: TimelineProps) {
return (
@@ -322,7 +333,9 @@ export function Timeline({ buckets, selection, onSelect }: TimelineProps) {
- {hover !== null && !drag &&
}
+ {hover !== null && !drag && (
+
+ )}
{selection && (