diff --git a/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.ts b/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.ts index e77e6c7bf0a..acf93fa481b 100644 --- a/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.ts +++ b/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.ts @@ -1,5 +1,6 @@ import { ApiError } from "@/lib/http/client"; -import type { TimeRange, TracesApi } from "@/components/lens/traces/api"; +import type { TracesApi } from "@/components/lens/traces/api"; +import type { TimeWindow } from "@/components/shared/timeRange/timeRange"; import { filterRuns, RUN_INDEX } from "@/components/lens/traces/list/runSearch/runQuery"; import type { TraceHistogram, TraceSummary } from "@/components/lens/traces/types"; import { traceAgentNames } from "@/components/lens/traces/utils"; @@ -41,13 +42,13 @@ function demoLensApi(data: LensDemoData): LensApi { }; } -const startedWithin = (run: TraceSummary, range: TimeRange): boolean => { +const startedWithin = (run: TraceSummary, range: TimeWindow): boolean => { const startMs = Date.parse(run.start_time); return startMs >= range.startMs && startMs < range.endMs; }; /** Runs per equal-width slice of the window, the shape the server's histogram returns. */ -export function demoHistogram(runs: readonly TraceSummary[], range: TimeRange, buckets: number): TraceHistogram { +export function demoHistogram(runs: readonly TraceSummary[], range: TimeWindow, buckets: number): TraceHistogram { const width = (range.endMs - range.startMs) / buckets; const placed = runs.map((run) => ({ index: Math.floor((Date.parse(run.start_time) - range.startMs) / width), @@ -75,7 +76,7 @@ export function demoHistogram(runs: readonly TraceSummary[], range: TimeRange, b function demoTracesApi(data: LensDemoData): TracesApi { const run = (traceId: string) => data.runs.find(({ trace }) => trace.summary.trace_id === traceId); const summaries = data.runs.map((item) => item.trace.summary); - const matching = (range: TimeRange, q: string) => + const matching = (range: TimeWindow, q: string) => filterRuns( summaries.filter((summary) => startedWithin(summary, range)), q, diff --git a/ui/litellm-dashboard/src/components/lens/traces/api.ts b/ui/litellm-dashboard/src/components/lens/traces/api.ts index aca4d7d0aa9..61fe4a8c275 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/api.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/api.ts @@ -9,14 +9,10 @@ import { apiClient, getProxyBaseUrl, } from "../../networking"; +import type { TimeWindow } from "@/components/shared/timeRange/timeRange"; import type { RunField, SpanDetail, SpanErrorPage, Trace, TraceHistogram, TracePage } from "./types"; -export interface TimeRange { - readonly startMs: number; - readonly endMs: number; -} - -export interface TraceWindow extends TimeRange { +export interface TraceWindow extends TimeWindow { readonly cursor?: string | null; } @@ -37,8 +33,8 @@ export interface TracesApi { readonly live: boolean; handoff(traceId: string, spanId?: string | null, traceRef?: string): TraceHandoff; list(request: RunListRequest): Promise; - histogram(range: TimeRange, q: string, buckets: number): Promise; - values(field: RunField, contains: string, range: TimeRange): Promise; + histogram(range: TimeWindow, q: string, buckets: number): Promise; + values(field: RunField, contains: string, range: TimeWindow): Promise; anyRecorded(): Promise; trace(traceId: string, traceRef?: string, cursor?: string | null): Promise; span(traceId: string, spanId: string, traceRef?: string): Promise; diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesPage.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesPage.tsx index dfc4b770994..26dbf26e923 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesPage.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesPage.tsx @@ -1,13 +1,9 @@ "use client"; -import moment from "moment"; -import { useMemo, useState } from "react"; +import { useRelativeRange } from "@/components/shared/timeRange/useRelativeRange"; import { AgentTracesSection } from "./AgentTracesSection"; import { useTracesLive } from "../api"; -import { useRangeHoursRouting } from "../routing"; - -const TIME_FORMAT = "YYYY-MM-DDTHH:mm:ss"; export default function AgentTracesPage({ accessToken, @@ -20,42 +16,16 @@ export default function AgentTracesPage({ readOnly?: boolean; canMintTracingKey?: boolean; }) { - const sourceLive = useTracesLive(); - const [rangeHours, setRangeHours] = useRangeHoursRouting(); - const [live, setLive] = useState(true); - const [anchor, setAnchor] = useState(() => moment()); - const { startTime, endTime } = useMemo( - () => ({ - startTime: anchor.clone().subtract(rangeHours, "hours").format(TIME_FORMAT), - endTime: anchor.format(TIME_FORMAT), - }), - [anchor, rangeHours], - ); - - const isLiveTail = live && sourceLive; - - const changeRange = (hours: number) => { - setRangeHours(hours); - setAnchor(moment()); - }; - - const changeLive = (next: boolean) => { - setLive(next); - setAnchor(moment()); - }; - + const time = useRelativeRange(useTracesLive()); return (
); diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx index 175783b382b..a0682897698 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx @@ -66,32 +66,21 @@ const serve = (data: readonly TraceSummary[]) => { const lastUrl = (onUrlUpdate: ReturnType) => new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? "")); +/** A day that rolls with now, as the page opens by default. */ +const ROLLING_DAY = { hours: 24, anchorMs: null }; +// A UTC-pinned day around the fixture runs (2026-09-30 ~06:43 UTC), so they land in the same bucket in any timezone. +const PINNED_DAY = { hours: 24, anchorMs: Date.parse("2026-10-01T00:00Z") }; + const renderSection = () => renderWithProviders( - + , ); -// A UTC-pinned day around the fixture runs (2026-09-30 ~06:43 UTC), so they land in the same bucket in any timezone. const renderWindowed = (timeControls?: TimeControls) => renderWithProviders( - , + , ); const bucketRunCounts = () => @@ -164,16 +153,7 @@ describe("AgentTracesSection", () => { ])("stops live polling after HTTP %s and explains how to recover", async (status, message) => { vi.useFakeTimers(); vi.mocked(agentTraceListCall).mockRejectedValue(new ApiError("Private token details", Number(status), {})); - renderWithProviders( - , - ); + renderWithProviders(); await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); @@ -216,17 +196,7 @@ describe("AgentTracesSection", () => { ])("keeps the runs controls instead of onboarding for %s", async (_, searchParams) => { pinNowToFixtures(); serve(runs); - renderWithProviders( - , - { searchParams }, - ); + renderWithProviders(, { searchParams }); expect(await screen.findByTestId("runs-table")).toBeInTheDocument(); await waitFor(() => expect(agentTraceListCall).toHaveBeenCalled()); expect(screen.queryAllByTestId("agent-trace-row")).toHaveLength(0); @@ -478,17 +448,10 @@ describe("AgentTracesSection", () => { it("opens full screen from a shared link and drops it from the URL on close", async () => { vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); const onUrlUpdate = vi.fn(); - renderWithProviders( - , - { searchParams: `?trace=${runs[0].trace_id}&fullscreen=true`, onUrlUpdate }, - ); + renderWithProviders(, { + searchParams: `?trace=${runs[0].trace_id}&fullscreen=true`, + onUrlUpdate, + }); const drawer = await screen.findByRole("complementary", { name: "Trace details" }); expect(drawer).toHaveStyle({ width: "100%" }); fireEvent.click(screen.getByRole("button", { name: "Close trace (Esc)" })); @@ -501,17 +464,10 @@ describe("AgentTracesSection", () => { const startMs = Date.parse(runs[0].start_time); const inWindow = runs.filter((run) => Math.abs(Date.parse(run.start_time) - startMs) <= 1); const onUrlUpdate = vi.fn(); - renderWithProviders( - , - { searchParams: `?from=${startMs - 1}&to=${startMs + 1}`, onUrlUpdate }, - ); + renderWithProviders(, { + searchParams: `?from=${startMs - 1}&to=${startMs + 1}`, + onUrlUpdate, + }); await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(inWindow.length)); expect(inWindow.length).toBeLessThan(runs.length); fireEvent.click(screen.getByRole("button", { name: "Clear time zoom" })); @@ -522,17 +478,10 @@ describe("AgentTracesSection", () => { it("opens the run named by ?trace= even when it is outside the loaded list, and clears it on close", async () => { vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); const onUrlUpdate = vi.fn(); - renderWithProviders( - , - { searchParams: "?trace=older-than-the-list&trace_ref=ref-9", onUrlUpdate }, - ); + renderWithProviders(, { + searchParams: "?trace=older-than-the-list&trace_ref=ref-9", + onUrlUpdate, + }); const drawer = await screen.findByRole("complementary", { name: "Trace details" }); expect(within(drawer).getByTestId("run-view")).toHaveTextContent("run older-than-the-list"); expect(screen.getByRole("button", { name: "Next trace (J)" })).toBeDisabled(); @@ -555,17 +504,10 @@ describe("AgentTracesSection", () => { serve(runs); const onUrlUpdate = vi.fn(); const failed = filterRuns(runs, "status:error"); - renderWithProviders( - , - { searchParams: "?q=status:error", onUrlUpdate }, - ); + renderWithProviders(, { + searchParams: "?q=status:error", + onUrlUpdate, + }); expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(failed.length); expect(failed.length).toBeLessThan(runs.length); const search = screen.getByRole("combobox", { name: "Search runs" }); @@ -662,7 +604,7 @@ describe("AgentTracesPage", () => { expect(trigger).toHaveTextContent(/ to /); expect(trigger).not.toHaveTextContent("Last 7 days"); - await waitFor(() => expect(vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0].endMs).toBe(pausedAt - 500)); + await waitFor(() => expect(vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0].endMs).toBe(pausedAt)); }); it("keeps the time controls on an empty range the user picked, instead of showing onboarding", async () => { 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 61fd228cec6..c82dfb29dfd 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx @@ -8,39 +8,28 @@ import { Inspector } from "@/components/shared/Inspector"; import { Button } from "@/components/ui/button"; import { AgentTracesTable } from "./AgentTracesTable"; -import { - type TraceRef, - traceKey, - traceRefOf, - useOpenTraceRouting, - useRunFilterRouting, - useZoomRouting, -} from "../routing"; +import { type TraceRef, traceKey, traceRefOf, useOpenTraceRouting, useRunFilterRouting } from "../routing"; import type { TraceSummary } from "../types"; import { RunView } from "../detail/run/RunView"; -import { TimeRangeControls } from "./TimeRangeControls"; +import { useZoomRouting } from "@/components/shared/timeRange/routing"; +import { type RelativeRange, timeWindow } from "@/components/shared/timeRange/timeRange"; +import { TimeRangeControls } from "@/components/shared/timeRange/TimeRangeControls"; +import type { RelativeRangeState } from "@/components/shared/timeRange/useRelativeRange"; import { TracesTimeline } from "./TracesTimeline"; import { TracingSetupCard } from "../../onboarding/tracing/TracingSetupCard"; import { useTracesLive } from "../api"; -import { type AgentTracesResult, traceWindowStartMs, useAgentTraces, useTraceAvailability } from "./useAgentTraces"; +import { type AgentTracesResult, useAgentTraces, useTraceAvailability } from "./useAgentTraces"; import { useTraceHistogram } from "./useTraceHistogram"; const DRAWER_WIDTH_KEY = "litellm.agentTraces.drawerWidth"; -export interface TimeControls { - rangeHours: number; - onRangeHoursChange: (hours: number) => void; - onLiveChange: (live: boolean) => void; -} +export type TimeControls = Pick; interface AgentTracesSectionProps { accessToken: string; isActive: boolean; - startTime: string; - endTime: string; - isCustomDate: boolean; - isLiveTail: boolean; - /** Page-owned time range + live state; when given, the toolbar shows the range / Live control group. */ + range: RelativeRange; + /** Page-owned range setters; when given, the toolbar shows the range / Live control group. */ timeControls?: TimeControls; readOnly?: boolean; canMintTracingKey?: boolean; @@ -77,10 +66,7 @@ function TraceHistoryError({ history }: { history: ReturnType ({ startMs: traceWindowStartMs(startTime, endTime, isCustomDate, endMs), endMs }), - [startTime, endTime, isCustomDate, endMs], - ); - const histogram = useTraceHistogram(accessToken, range, query, isActive); + // A live range ends "now" (the list query uses Date.now() too); round to the minute so the histogram is stable. + const minuteEndMs = moment().endOf("minute").valueOf(); + const window = useMemo(() => timeWindow(range, minuteEndMs), [range, minuteEndMs]); + const histogram = useTraceHistogram(accessToken, window, query, isActive); const runs = traces.traces; const runRefs = useMemo(() => runs.map(traceRefOf), [runs]); - const changeRange = (hours: number, apply: (hours: number) => void) => { + const changeHours = (hours: number, apply: (hours: number) => void) => { setZoom(null); setRangeChanged(true); apply(hours); @@ -179,22 +162,21 @@ export function AgentTracesSection({ /> )} - + {timeControls && ( changeRange(hours, timeControls.onRangeHoursChange)} - live={isLiveTail} + range={range} + zoom={zoom} + onHoursChange={(hours) => changeHours(hours, timeControls.setHours)} showLive={live} - onLiveChange={timeControls.onLiveChange} + onLiveChange={timeControls.setLive} /> )} 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 39f92475ef4..d8ba2b7721a 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx @@ -17,9 +17,7 @@ const DAY_MS = 24 * HOUR_MS; const EDGE_FORMAT = "MMM DD, HH:mm"; const SKELETON_COLUMNS = skeletonColumns(BUCKETS); -import type { TimeRange as TimeWindow } from "../api"; - -export type { TimeWindow }; +import type { TimeWindow } from "@/components/shared/timeRange/timeRange"; export interface Bucket extends DotColumn { startMs: number; 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..fa6b4a6a87b 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/timeRange/timeRange"; 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..c881c3be0bc 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/timeRange/timeRange"; 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 a82ffcad94a..b155945ddb5 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/timeRange/timeRange"; import { isNegatedOp, 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/list/useAgentTraces.test.ts b/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.test.ts index 039ef950768..d13a84defeb 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.test.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.test.ts @@ -1,30 +1,7 @@ import { describe, expect, it } from "vitest"; -import { traceWindowStartMs } from "./useAgentTraces"; import { spanLogWindow } from "../detail/useSpanRequestLog"; -const HOUR = 3600 * 1000; - -describe("traceWindowStartMs", () => { - it("rolls a preset range forward with now so live tail keeps a fixed-length window", () => { - const start = "2026-09-29T10:00"; - const end = "2026-09-30T10:00"; - const mountedAt = Date.parse("2026-09-30T10:00:00"); - const tenHoursLater = mountedAt + 10 * HOUR; - expect(tenHoursLater - traceWindowStartMs(start, end, false, tenHoursLater)).toBe(24 * HOUR); - expect(traceWindowStartMs(start, end, false, tenHoursLater)).toBeGreaterThan( - traceWindowStartMs(start, end, false, mountedAt), - ); - }); - - it("keeps a custom range pinned to what the user picked", () => { - const start = "2026-09-01T00:00"; - expect(traceWindowStartMs(start, "2026-09-02T00:00", true, Date.parse("2026-09-30T00:00:00"))).toBe( - Date.parse(start), - ); - }); -}); - describe("spanLogWindow", () => { it("looks up the request log around the span's own time, not the logs tab window", () => { const spanStart = Date.parse("2026-08-01T12:00:00Z"); diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.ts b/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.ts index 40470ed93ae..a17fbf9155d 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.ts @@ -1,14 +1,18 @@ import { useTracesApi } from "../api"; import { keepPreviousData, useInfiniteQuery, useQuery, type UseQueryOptions } from "@tanstack/react-query"; -import moment from "moment"; import { useMemo } from "react"; import { ApiError } from "@/lib/http/client"; +import { + isLive, + LIVE_TAIL_INTERVAL_MS, + type RelativeRange, + type TimeWindow, + timeWindow, +} from "@/components/shared/timeRange/timeRange"; import type { TracePage, TraceSummary } from "../types"; -import type { TimeRange, TraceWindow } from "../api"; - -const LIVE_TAIL_INTERVAL_MS = 15000; +import type { TraceWindow } from "../api"; interface LoadedTracePage extends TracePage { window: TraceWindow; @@ -36,15 +40,12 @@ const displayError = (error: Error | null): Error | null => { interface UseAgentTracesOptions { accessToken: string; - startTime: string; - endTime: string; - isCustomDate: boolean; - isLiveTail: boolean; + range: RelativeRange; enabled: boolean; /** The search the server applies before paging. */ q: string; /** A window inside the range that replaces it for the list. */ - zoom: TimeRange | null; + zoom: TimeWindow | null; } export interface AgentTracesResult { @@ -59,36 +60,19 @@ export interface AgentTracesResult { refetch: () => void; } -/** Start of the fetch window: a preset range rolls with "now", so live tail keeps a fixed-length window. */ -export const traceWindowStartMs = (startTime: string, endTime: string, isCustomDate: boolean, nowMs: number): number => - isCustomDate ? moment(startTime).valueOf() : nowMs - (moment(endTime).valueOf() - moment(startTime).valueOf()); - /** - * GET /v1/traces for the Logs page time range (or the zoom inside it) and search, cursor-paginated as the runs - * list scrolls. Preset ranges roll on refresh; subsequent pages keep the first page's window. + * GET /v1/traces for the range (or the zoom inside it) and search, cursor-paginated as the runs list scrolls. + * A live range rolls on refresh; subsequent pages keep the first page's window. */ -export function useAgentTraces({ - accessToken, - startTime, - endTime, - isCustomDate, - isLiveTail, - enabled, - q, - zoom, -}: UseAgentTracesOptions): AgentTracesResult { +export function useAgentTraces({ accessToken, range, enabled, q, zoom }: UseAgentTracesOptions): AgentTracesResult { const traces = useTracesApi(accessToken); + const isLiveTail = isLive(range); const fetchPage = async (pageParam: unknown): Promise => { - const nowMs = Date.now(); - const window = (pageParam as TraceWindow | null) ?? - zoom ?? { - startMs: traceWindowStartMs(startTime, endTime, isCustomDate, nowMs), - endMs: isCustomDate ? moment(endTime).valueOf() : nowMs, - }; + const window = (pageParam as TraceWindow | null) ?? zoom ?? timeWindow(range, Date.now()); return { ...(await traces.list({ ...window, q })), window }; }; const queryOptions: Parameters>[0] = { - queryKey: ["agentTraces", traces.scope, startTime, endTime, isCustomDate, q, zoom], + queryKey: ["agentTraces", traces.scope, range.hours, range.anchorMs, q, zoom], placeholderData: keepPreviousData, queryFn: ({ pageParam }) => fetchPage(pageParam), initialPageParam: null, diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/useTraceHistogram.ts b/ui/litellm-dashboard/src/components/lens/traces/list/useTraceHistogram.ts index 7d192eedb17..524d89bb8df 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/useTraceHistogram.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/list/useTraceHistogram.ts @@ -1,6 +1,6 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import type { TimeRange } from "../api"; +import type { TimeWindow } from "@/components/shared/timeRange/timeRange"; import { useTracesApi } from "../api"; import type { TraceHistogram } from "../types"; import { BUCKETS, type Bucket } from "./TracesTimeline"; @@ -15,7 +15,7 @@ export const toBuckets = (histogram: TraceHistogram): Bucket[] => series: bucket.agents.flatMap(({ agent, runs }) => Array(runs).fill(agent)), })); -const emptyBuckets = (range: TimeRange): Bucket[] => { +const emptyBuckets = (range: TimeWindow): Bucket[] => { const width = (range.endMs - range.startMs) / BUCKETS; return Array.from({ length: BUCKETS }, (_, i) => ({ startMs: range.startMs + i * width, @@ -35,7 +35,7 @@ export interface TraceHistogramResult { /** Matching runs per bucket across the whole range, counted by the server so every run is plotted, not just loaded ones. */ export function useTraceHistogram( accessToken: string, - range: TimeRange, + range: TimeWindow, q: string, enabled: boolean, ): TraceHistogramResult { diff --git a/ui/litellm-dashboard/src/components/lens/traces/routing.ts b/ui/litellm-dashboard/src/components/lens/traces/routing.ts index 744be6bba2b..46f261a9140 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/routing.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/routing.ts @@ -1,16 +1,7 @@ -import { - parseAsBoolean, - parseAsInteger, - parseAsNumberLiteral, - parseAsString, - parseAsStringLiteral, - useQueryState, - useQueryStates, -} from "nuqs"; +import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryState, useQueryStates } from "nuqs"; import { useCallback, useState } from "react"; -import { RANGE_PRESETS } from "./list/TimeRangeControls"; -import type { TimeWindow } from "./list/TracesTimeline"; +import { TIME_RANGE_PARSERS } from "@/components/shared/timeRange/routing"; import type { TraceSummary } from "./types"; export const TRACE_VIEWS = ["steps", "conversation"] as const; @@ -19,11 +10,6 @@ export type TraceView = (typeof TRACE_VIEWS)[number]; export const SPAN_TABS = ["content", "request", "attributes"] as const; export type SpanTab = (typeof SPAN_TABS)[number]; -const RANGE_HOURS = RANGE_PRESETS.map((preset) => preset.hours); -type RangeHours = (typeof RANGE_PRESETS)[number]["hours"]; -const isRangeHours = (hours: number): hours is RangeHours => RANGE_HOURS.includes(hours as RangeHours); -export const DEFAULT_RANGE_HOURS = 24; - export interface TraceRef { traceId: string; traceRef?: string; @@ -59,9 +45,7 @@ export const OPEN_TRACE_PARSERS = { export const RUN_FILTER_PARSERS = { q: parseAsString.withDefault(""), - hours: parseAsNumberLiteral(RANGE_HOURS).withDefault(DEFAULT_RANGE_HOURS), - from: parseAsInteger, - to: parseAsInteger, + ...TIME_RANGE_PARSERS, }; export interface OpenTraceRouting { @@ -143,19 +127,3 @@ export function useRunFilterRouting(): { query: string; setQuery: (query: string const [query, setQuery] = useQueryState("q", RUN_FILTER_PARSERS.q); return { query, setQuery: useCallback((q: string) => void setQuery(q), [setQuery]) }; } - -export function useRangeHoursRouting(): [number, (hours: number) => void] { - const [hours, setHours] = useQueryState("hours", RUN_FILTER_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: RUN_FILTER_PARSERS.from, to: RUN_FILTER_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/lens/traces/list/TimeRangeControls.tsx b/ui/litellm-dashboard/src/components/shared/timeRange/TimeRangeControls.tsx similarity index 63% rename from ui/litellm-dashboard/src/components/lens/traces/list/TimeRangeControls.tsx rename to ui/litellm-dashboard/src/components/shared/timeRange/TimeRangeControls.tsx index 2f3c115119e..de55f929dd1 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/TimeRangeControls.tsx +++ b/ui/litellm-dashboard/src/components/shared/timeRange/TimeRangeControls.tsx @@ -12,59 +12,47 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/cva.config"; -import type { TimeWindow } from "./TracesTimeline"; - -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, - showLive = true, - onLiveChange, -}: TimeRangeControlsProps) { +export function TimeRangeControls({ range, zoom, onHoursChange, showLive = true, onLiveChange }: TimeRangeControlsProps) { + const live = isLive(range); return (
- {fixedRange ? fixedRangeLabel(fixedRange) : presetLabel(rangeHours)} + {rangeLabel(range, zoom)} - + onRangeHoursChange(Number(value))} + value={String(range.hours)} + onValueChange={(value: string) => onHoursChange(Number(value))} > {RANGE_PRESETS.map((preset) => ( 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()); + }, + }; +}