From c3e0156979e07a7a9895949e4ef5059e4bb038ff Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:27:00 -0700 Subject: [PATCH] fix(ui): polish Lens runs loading, reload, and time range menu (#44789) * fix(ui): polish Lens runs loading, reload, and time range menu Port the dashboard-only parts of a0a275e486, 1325389624, 5e7b0afd5c, 03bec959bf, 93e6ccff8d, 3d35d9f920, dc1a2b5b60, 1d0397f1c0, 773bfef066, f3387221ea and 3d049cd4a8 from litellm_lens_server_search onto main Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep the Lens timeline on the shown runs' window during a reload Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(ui): drop a redundant fixture comment Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Yujong Lee Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/app/globals.css | 2 +- .../investigations/InvestigationStates.tsx | 9 +- .../InvestigationsView.integration.test.tsx | 21 +++ .../lens/traces/detail/run/RunView.test.tsx | 21 ++- .../lens/traces/detail/run/RunView.tsx | 22 +-- .../lens/traces/list/AgentTracesPage.tsx | 38 +---- .../AgentTracesSection.integration.test.tsx | 150 +++++++++--------- .../lens/traces/list/AgentTracesSection.tsx | 68 ++++---- .../lens/traces/list/AgentTracesTable.tsx | 9 +- .../lens/traces/list/TracesTimeline.tsx | 9 +- .../lens/traces/list/runSearch/RunSearch.tsx | 6 +- .../traces/list/runSearch/RunsToolbar.tsx | 8 +- .../lens/traces/list/runSearch/runSql.ts | 2 +- .../lens/traces/list/useAgentTraces.test.ts | 23 --- .../lens/traces/list/useAgentTraces.ts | 47 +++--- .../src/components/lens/traces/routing.ts | 38 +---- .../src/components/shared/InspectorTable.tsx | 5 +- .../src/components/shared/LoadingState.tsx | 17 ++ .../{lens/ui => shared}/StateMessage.tsx | 0 .../shared/search/SearchBox.test.tsx | 18 +++ .../components/shared/search/SearchBox.tsx | 16 +- .../TimeRangeControls.tsx | 46 +++--- .../components/shared/timeRange/routing.ts | 26 +++ .../shared/timeRange/timeRange.test.ts | 32 ++++ .../components/shared/timeRange/timeRange.ts | 38 +++++ .../shared/timeRange/useRelativeRange.ts | 30 ++++ .../shared/timeline/Timeline.test.ts | 4 +- .../components/shared/timeline/Timeline.tsx | 43 +++-- 28 files changed, 428 insertions(+), 320 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/LoadingState.tsx rename ui/litellm-dashboard/src/components/{lens/ui => shared}/StateMessage.tsx (100%) rename ui/litellm-dashboard/src/components/shared/{timeline => timeRange}/TimeRangeControls.tsx (65%) create mode 100644 ui/litellm-dashboard/src/components/shared/timeRange/routing.ts create mode 100644 ui/litellm-dashboard/src/components/shared/timeRange/timeRange.test.ts create mode 100644 ui/litellm-dashboard/src/components/shared/timeRange/timeRange.ts create mode 100644 ui/litellm-dashboard/src/components/shared/timeRange/useRelativeRange.ts diff --git a/ui/litellm-dashboard/src/app/globals.css b/ui/litellm-dashboard/src/app/globals.css index ccc8e7414ed..3bc500c4848 100644 --- a/ui/litellm-dashboard/src/app/globals.css +++ b/ui/litellm-dashboard/src/app/globals.css @@ -471,7 +471,7 @@ visibility: hidden; } -@keyframes lens-sweep { +@keyframes timeline-sweep { from { left: -6rem; } diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationStates.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationStates.tsx index aa313cd6e37..d57ff2c9210 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationStates.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationStates.tsx @@ -1,8 +1,9 @@ "use client"; -import { ArrowUpRight, Loader2, SearchX, TriangleAlert } from "lucide-react"; +import { ArrowUpRight, SearchX, TriangleAlert } from "lucide-react"; +import { LoadingState } from "@/components/shared/LoadingState"; +import { StateMessage } from "@/components/shared/StateMessage"; import { Button, buttonVariants } from "@/components/ui/button"; -import { StateMessage } from "../ui/StateMessage"; import { ApiError } from "@/lib/http/client"; @@ -63,9 +64,7 @@ export function InvestigationError({ message, refresh }: { message: string; refr export function InvestigationsLoading() { return ( - } + diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx index 981d8344eca..fdf13a80443 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx @@ -508,6 +508,27 @@ it("allows retrying a failed trace readiness check without treating it as an emp expect(traceCheck).toHaveBeenCalledTimes(2); }); +it("shows a loading state until the investigation list arrives", async () => { + window.history.replaceState({}, "", "/lens/"); + testQueryClient.clear(); + let resolveList: (list: unknown) => void = () => {}; + proxy.get.mockImplementation((path) => { + if (path === "/lens") + return new Promise((resolve) => { + resolveList = resolve; + }); + if (path === "/lens/agents") return Promise.resolve([]); + return Promise.resolve({ traces: true, requests: false, data: [] }); + }); + renderWithProviders(); + expect(await screen.findByRole("status", { name: "Loading investigations…" })).toBeVisible(); + expect(screen.queryByRole("region", { name: "Get Lens running" })).not.toBeInTheDocument(); + + act(() => resolveList({ lenses: [], workers: [], tracing_enabled: true })); + expect(await screen.findByRole("region", { name: "Get Lens running" })).toBeVisible(); + expect(screen.queryByRole("status", { name: "Loading investigations…" })).not.toBeInTheDocument(); +}); + it("shows a centered failure with a retry when investigations cannot load, then recovers", async () => { window.history.replaceState({}, "", "/lens/"); testQueryClient.clear(); diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx index 8f23031036d..d44b0c42621 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx @@ -144,6 +144,25 @@ describe("RunView", () => { expect(screen.getByLabelText("Keyboard shortcuts")).toHaveTextContent("↑/↓ step←/→ foldEsc close"); }); + it("shows a loading state until the first trace arrives", async () => { + let resolveTrace: (trace: Trace) => void = () => {}; + vi.mocked(agentTraceCall).mockImplementation( + () => + new Promise((resolve) => { + resolveTrace = resolve; + }), + ); + renderWithProviders( + , + ); + expect(screen.getByRole("status", { name: "Loading trace…" })).toBeVisible(); + expect(screen.queryByTestId("run-view")).not.toBeInTheDocument(); + + act(() => resolveTrace(research)); + expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary)); + expect(screen.queryByRole("status", { name: "Loading trace…" })).not.toBeInTheDocument(); + }); + it("keeps the current run on screen, inert, while an unvisited run loads in the drawer", async () => { const user = userEvent.setup(); let resolveSwarm: (trace: Trace) => void = () => {}; @@ -161,7 +180,7 @@ describe("RunView", () => { rerender(); await waitFor(() => expect(screen.getByTestId("run-view")).toHaveAttribute("aria-busy", "true")); - expect(screen.queryByRole("status", { name: "Loading trace" })).not.toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Loading trace…" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary)); await user.keyboard("{ArrowDown}"); expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", root); diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx index 55ea7fb0984..4d78b136c17 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx @@ -5,9 +5,9 @@ import { ArrowLeft } from "lucide-react"; import { Suspense, useDeferredValue, useEffect, useMemo } from "react"; import { ErrorBoundary } from "react-error-boundary"; +import { LoadingState } from "@/components/shared/LoadingState"; import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs"; -import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { cn } from "@/lib/cva.config"; import { useTracesApi } from "../../api"; @@ -32,22 +32,8 @@ function selectedSpanMissing(trace: Trace, spanId: string | null): boolean { return Boolean(spanId && !trace.spans.some((span) => span.span_id === spanId)); } -function RunLoading({ embedded }: { embedded: boolean }) { - return ( -
- {embedded ? ( - [72, 48, 88, 60, 80].map((w) => ( -
- )) - ) : ( - - )} -
- ); +function RunLoading() { + return ; } function RunLoadError({ error, onBack, onRetry }: { error: unknown; onBack: () => void; onRetry: () => void }) { @@ -85,7 +71,7 @@ export function RunView(props: RunViewProps) { )} > - }> + }> 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 900c6574da4..8c099f7226a 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, @@ -22,43 +18,17 @@ export default function AgentTracesPage({ canMintTracingKey?: boolean; canViewFindings?: 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 d884bba3307..8103941986e 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 @@ -9,7 +9,9 @@ import { renderWithProviders, testQueryClient } from "../../../../../tests/test- import traceList from "../__fixtures__/trace_list.json"; import AgentTracesPage from "./AgentTracesPage"; import { filterRuns } from "./runSearch/runQuery"; -import { AgentTracesSection, type TimeControls } from "./AgentTracesSection"; +import type { RelativeRangeState } from "@/components/shared/timeRange/useRelativeRange"; + +import { AgentTracesSection } from "./AgentTracesSection"; import type { TraceFindingCount, TracePage, TraceSummary } from "../types"; vi.mock("../../../networking", () => ({ @@ -37,35 +39,22 @@ const runs = (traceList as TracePage).data as TraceSummary[]; const lastUrl = (onUrlUpdate: ReturnType) => new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? "")); +const ROLLING_DAY = { hours: 24, anchorMs: null }; +const PINNED_DAY = { hours: 24, anchorMs: Date.parse("2026-10-01T00:00Z") }; + const renderSection = (canViewFindings = true) => 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) => +const renderWindowed = (timeControls?: RelativeRangeState) => renderWithProviders( - , + , ); const bucketRunCounts = () => - screen.getAllByTestId("timeline-bucket").map((bucket) => Number(bucket.getAttribute("data-runs"))); + screen.getAllByTestId("timeline-bucket").map((bucket) => Number(bucket.getAttribute("data-total"))); describe("AgentTracesSection", () => { afterEach(() => { @@ -156,16 +145,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); }); @@ -509,17 +489,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)" })); @@ -532,17 +505,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" })); @@ -553,17 +519,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(); @@ -587,17 +546,10 @@ describe("AgentTracesSection", () => { const mixed = runs.map((run, index) => (index === 1 ? { ...run, status: "error" as const } : run)); vi.mocked(agentTraceListCall).mockResolvedValue({ data: mixed, next_cursor: null }); const failed = filterRuns(mixed, "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" }); @@ -615,7 +567,7 @@ describe("AgentTracesSection", () => { renderWindowed(); await screen.findAllByTestId("agent-trace-row"); - expect(screen.getByTestId("traces-timeline")).toBeInTheDocument(); + expect(screen.getByTestId("timeline")).toBeInTheDocument(); const counts = bucketRunCounts(); expect(counts).toHaveLength(60); expect(counts.reduce((a, b) => a + b, 0)).toBe(runs.length); @@ -651,7 +603,7 @@ describe("AgentTracesSection", () => { drag(screen.getByTestId("timeline-selection"), 0, 59); expect(rowCount()).toBe(0); - fireEvent.keyDown(screen.getByTestId("traces-timeline"), { key: "Escape" }); + fireEvent.keyDown(screen.getByTestId("timeline"), { key: "Escape" }); expect(screen.queryByTestId("timeline-selection")).not.toBeInTheDocument(); expect(rowCount()).toBe(runs.length); }); @@ -693,7 +645,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 () => { @@ -727,6 +679,48 @@ describe("AgentTracesPage", () => { await waitFor(() => expect(lastUrl(onUrlUpdate).get("hours")).toBe("1")); }); + it("keeps the shown runs behind a search spinner while a newly picked range loads", async () => { + vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + renderWithProviders(); + expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(runs.length); + expect(screen.queryByRole("status", { name: "Loading results" })).not.toBeInTheDocument(); + + let resolveWeek: (page: TracePage) => void = () => {}; + vi.mocked(agentTraceListCall).mockImplementation( + () => + new Promise((resolve) => { + resolveWeek = resolve; + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Time range" })); + fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last 7 days" })); + + expect(await screen.findByRole("status", { name: "Loading results" })).toBeInTheDocument(); + expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length); + expect(screen.queryByText("Loading runs…")).not.toBeInTheDocument(); + + act(() => resolveWeek({ ...(traceList as TracePage), data: runs.slice(0, 1) })); + await waitFor(() => expect(screen.queryByRole("status", { name: "Loading results" })).not.toBeInTheDocument()); + expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1); + }); + + it("keeps the timeline on the shown runs' window while a narrower range loads", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: Date.parse("2026-09-30T12:00Z") }); + vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + renderWithProviders(, { searchParams: "?hours=24" }); + expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(runs.length); + const sum = () => bucketRunCounts().reduce((a, b) => a + b, 0); + expect(sum()).toBe(runs.length); + + vi.mocked(agentTraceListCall).mockImplementation(() => new Promise(() => {})); + fireEvent.click(screen.getByRole("button", { name: "Time range" })); + fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last hour" })); + + expect(await screen.findByRole("status", { name: "Loading results" })).toBeInTheDocument(); + expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length); + expect(sum()).toBe(runs.length); + }); + it("asks the proxy for the last 24 hours by default", async () => { vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); renderWithProviders(); 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 5c711dc2183..3786d61bfeb 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx @@ -12,22 +12,17 @@ import { Button } from "@/components/ui/button"; import { AgentTracesTable } from "./AgentTracesTable"; import { useTraceFindings } from "./useTraceFindings"; -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 "@/components/shared/timeline/TimeRangeControls"; +import { useZoomRouting } from "@/components/shared/timeRange/routing"; +import { type RelativeRange, type TimeWindow, 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 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"; +import { type AgentTracesResult, useAgentTraces, useTraceAvailability } from "./useAgentTraces"; const DRAWER_WIDTH_KEY = "litellm.agentTraces.drawerWidth"; @@ -37,21 +32,12 @@ const filterByWindow = (runs: TraceSummary[], range: TimeWindow): TraceSummary[] return t >= range.startMs && t < range.endMs; }); -export interface TimeControls { - rangeHours: number; - onRangeHoursChange: (hours: number) => void; - onLiveChange: (live: boolean) => void; -} - interface AgentTracesSectionProps { accessToken: string; isActive: boolean; - startTime: string; - endTime: string; - isCustomDate: boolean; - isLiveTail: boolean; + range: RelativeRange; /** Page-owned time range + live state; when given, the toolbar shows the range / Live control group. */ - timeControls?: TimeControls; + timeControls?: RelativeRangeState; readOnly?: boolean; canMintTracingKey?: boolean; canViewFindings?: boolean; @@ -88,10 +74,7 @@ function TraceHistoryError({ history }: { history: ReturnType ({ startMs: traceWindowStartMs(startTime, endTime, isCustomDate, endMs), endMs }), - [startTime, endTime, isCustomDate, endMs], - ); + // 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 pickedWindow = useMemo(() => timeWindow(range, minuteEndMs), [range, minuteEndMs]); + // While the previous range's rows stay on screen, describe them with their own window. + const window = traces.isPlaceholder && traces.window ? traces.window : pickedWindow; const filtered = useMemo( () => filterRuns(traces.traces, query, { agent, status }), [traces.traces, query, agent, status], @@ -194,7 +176,13 @@ export function AgentTracesSection({ /> )} - + 0} live={live} onSetup={() => setShowSetup(true)} />
)} - + void; onLoadMore: () => void; rangeEmpty?: boolean; @@ -211,6 +214,8 @@ function EmptyRuns({ rangeEmpty, onSetUpTracing }: { rangeEmpty: boolean; onSetU ); } +const bodyClassName = (blurred?: boolean) => cn("transition-[filter]", blurred && "blur-[1.5px]"); + /** Devtool-dense runs list: one row per agent run, newest first. */ export function AgentTracesTable({ traces, @@ -220,6 +225,7 @@ export function AgentTracesTable({ error, hasMore, isFetching = false, + isPlaceholder, onRetry, onLoadMore, rangeEmpty = false, @@ -227,7 +233,7 @@ export function AgentTracesTable({ }: AgentTracesTableProps) { const settled = !isLoading && !error; const isEmpty = settled && !hasMore && traces.length === 0; - const canContinue = settled && hasMore; + const canContinue = settled && hasMore && !isPlaceholder; const autoContinue = canContinue && traces.length > 0; const { columnVisibility, onColumnVisibilityChange } = usePersistedColumnVisibility("lens-traces"); const tableOptions: TableOptions = { @@ -247,6 +253,7 @@ export function AgentTracesTable({ + className={bodyClassName(isPlaceholder)} rowHeight={() => ROW_HEIGHT} after={ <> 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 71c334efe4f..3e39720a537 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx @@ -3,13 +3,14 @@ import moment from "moment"; import { useMemo } from "react"; -import { Timeline, TIMELINE_BUCKETS, type Bucket, type TimeWindow } from "@/components/shared/timeline/Timeline"; +import type { TimeWindow } from "@/components/shared/timeRange/timeRange"; +import { Timeline, TIMELINE_BUCKETS, type TimeBucket } from "@/components/shared/timeline/Timeline"; import type { TraceSummary } from "../types"; import { traceAgentNames } from "../utils"; /** Run counts per equal-width time bucket across the window; runs outside it are dropped. */ -export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buckets = TIMELINE_BUCKETS): Bucket[] { +export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buckets = TIMELINE_BUCKETS): TimeBucket[] { const width = (range.endMs - range.startMs) / buckets; const placed = runs.map((run) => ({ index: Math.floor((moment(run.start_time).valueOf() - range.startMs) / width), @@ -36,7 +37,9 @@ interface TracesTimelineProps { } /** Lens adapter: buckets the currently loaded runs and hands them to the shared timeline. */ +const RUN_NOUN = { singular: "run", plural: "runs" }; + export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimelineProps) { const buckets = useMemo(() => bucketRuns(runs, range), [runs, range]); - return ; + 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 bf07ad61a78..fa72b258999 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 "@/components/shared/timeline/Timeline"; +import type { TimeWindow } from "@/components/shared/timeRange/timeRange"; import { SearchBox } from "@/components/shared/search/SearchBox"; import { itemValues } from "@/components/shared/search/valueSource"; @@ -19,10 +19,11 @@ interface RunSearchProps { runs: readonly TraceSummary[]; /** The range the list shows; the copied query bounds itself to it. */ range?: TimeWindow; + busy?: boolean; } /** The runs list query box: free text plus `key:value` filters over run fields, copyable as a trace query. */ -export function RunSearch({ value, onChange, runs, range }: RunSearchProps) { +export function RunSearch({ value, onChange, runs, range, busy = false }: RunSearchProps) { const command = useMemo(() => runQueryCommand(range), [range]); return ( 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 cdf0f1a34a3..3bd47a85eb1 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 "@/components/shared/timeline/Timeline"; +import type { TimeWindow } from "@/components/shared/timeRange/timeRange"; import { Combobox, @@ -22,11 +22,13 @@ interface RunsToolbarProps { runs: readonly TraceSummary[]; /** The range the list shows, for the copied query. */ range?: TimeWindow; + /** The list is reloading for a new range; the search box shows a spinner. */ + busy?: boolean; /** Extra controls (time range, live tail) rendered on the right. */ children?: React.ReactNode; } -export function RunsToolbar({ query, onQueryChange, runs, range, children }: RunsToolbarProps) { +export function RunsToolbar({ query, onQueryChange, runs, range, busy, children }: RunsToolbarProps) { const { agent, status, setAgent, setStatus } = useRunFilterRouting(); const agents = [...new Set([...runs.flatMap(traceAgentNames), ...(agent ? [agent] : [])])].sort(); const statuses = [ @@ -36,7 +38,7 @@ export function RunsToolbar({ query, onQueryChange, runs, range, children }: Run ]; return (
- + setAgent(name ?? "")} autoHighlight> { - 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 d82f224ef58..9bba13a328d 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.ts @@ -1,15 +1,19 @@ import { useTracesApi } from "../api"; -import { useInfiniteQuery, useQuery, type UseQueryOptions } from "@tanstack/react-query"; -import moment from "moment"; +import { keepPreviousData, useInfiniteQuery, useQuery, type UseQueryOptions } from "@tanstack/react-query"; import { useMemo } from "react"; +import { + isLive, + LIVE_TAIL_INTERVAL_MS, + type RelativeRange, + type TimeWindow, + timeWindow, +} from "@/components/shared/timeRange/timeRange"; import { ApiError } from "@/lib/http/client"; import type { TracePage, TraceSummary } from "../types"; import type { TraceWindow } from "../api"; -const LIVE_TAIL_INTERVAL_MS = 15000; - interface LoadedTracePage extends TracePage { window: TraceWindow; } @@ -36,10 +40,7 @@ const displayError = (error: Error | null): Error | null => { interface UseAgentTracesOptions { accessToken: string; - startTime: string; - endTime: string; - isCustomDate: boolean; - isLiveTail: boolean; + range: RelativeRange; enabled: boolean; } @@ -47,6 +48,10 @@ export interface AgentTracesResult { traces: TraceSummary[]; isLoading: boolean; isFetching: boolean; + /** The previous range's rows, still shown while the newly picked range loads. */ + isPlaceholder: boolean; + /** The window the shown rows were fetched for; lags the picked range while a placeholder is shown. */ + window: TimeWindow | null; /** Set when the proxy answered 501: tracing isn't configured. */ notEnabledDetail: string | null; error: Error | null; @@ -55,41 +60,27 @@ 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, cursor-paginated as the runs list scrolls. * Preset ranges roll on refresh; subsequent pages keep the first page's window. */ -export function useAgentTraces({ - accessToken, - startTime, - endTime, - isCustomDate, - isLiveTail, - enabled, -}: UseAgentTracesOptions): AgentTracesResult { +export function useAgentTraces({ accessToken, range, enabled }: UseAgentTracesOptions): AgentTracesResult { const traces = useTracesApi(accessToken); const fetchPage = async (pageParam: unknown): Promise => { - const nowMs = Date.now(); - const window = (pageParam as TraceWindow | null) ?? { - startMs: traceWindowStartMs(startTime, endTime, isCustomDate, nowMs), - endMs: isCustomDate ? moment(endTime).valueOf() : nowMs, - }; + const window = (pageParam as TraceWindow | null) ?? timeWindow(range, Date.now()); return { ...(await traces.list(window)), window }; }; const queryOptions: Parameters>[0] = { - queryKey: ["agentTraces", accessToken, startTime, endTime, isCustomDate], + queryKey: ["agentTraces", accessToken, range.hours, range.anchorMs], queryFn: ({ pageParam }) => fetchPage(pageParam), initialPageParam: null, getNextPageParam: (lastPage) => lastPage.next_cursor ? { ...lastPage.window, cursor: lastPage.next_cursor } : undefined, enabled, + placeholderData: keepPreviousData, staleTime: LIVE_TAIL_INTERVAL_MS, retry: (failureCount, error) => !requiresUserAction(error) && failureCount < 1, - refetchInterval: (q) => (isLiveTail && !requiresUserAction(q.state.error) ? LIVE_TAIL_INTERVAL_MS : false), + refetchInterval: (q) => (isLive(range) && !requiresUserAction(q.state.error) ? LIVE_TAIL_INTERVAL_MS : false), refetchOnWindowFocus: (q) => !requiresUserAction(q.state.error), refetchOnReconnect: (q) => !requiresUserAction(q.state.error), refetchIntervalInBackground: false, @@ -103,6 +94,8 @@ export function useAgentTraces({ traces: loaded, isLoading: query.isLoading, isFetching: query.isFetching, + isPlaceholder: query.isPlaceholderData, + window: query.data?.pages[0]?.window ?? null, notEnabledDetail: notEnabled ? query.error?.message || "Agent tracing is not enabled" : null, error: notEnabled ? null : displayError(query.error), hasMore: query.hasNextPage, diff --git a/ui/litellm-dashboard/src/components/lens/traces/routing.ts b/ui/litellm-dashboard/src/components/lens/traces/routing.ts index e63f9c59343..3788dc2dff7 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, useQueryStates } from "nuqs"; import { useCallback, useState } from "react"; -import { RANGE_PRESETS } from "@/components/shared/timeline/TimeRangeControls"; -import type { TimeWindow } from "@/components/shared/timeline/Timeline"; +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; @@ -72,9 +58,7 @@ export const RUN_FILTER_PARSERS = { q: parseAsString.withDefault(""), agent: parseAsString.withDefault(""), status: parseAsStringLiteral(["all", "ok", "error"]).withDefault("all"), - hours: parseAsNumberLiteral(RANGE_HOURS).withDefault(DEFAULT_RANGE_HOURS), - from: parseAsInteger, - to: parseAsInteger, + ...TIME_RANGE_PARSERS, }; export interface OpenTraceRouting { @@ -163,19 +147,3 @@ export function useRunFilterRouting() { setStatus: (status: "all" | "ok" | "error") => void setParams({ status }), }; } - -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/shared/InspectorTable.tsx b/ui/litellm-dashboard/src/components/shared/InspectorTable.tsx index 059ad1673ee..d59c447186c 100644 --- a/ui/litellm-dashboard/src/components/shared/InspectorTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/InspectorTable.tsx @@ -93,9 +93,10 @@ interface BodyProps { readonly rowHeight: (row: TanStackRow) => number; readonly children: (row: TanStackRow) => ReactNode; readonly after?: ReactNode; + readonly className?: string; } -function Body({ rowHeight, children, after }: BodyProps) { +function Body({ rowHeight, children, after, className }: BodyProps) { const { table, scroller } = useInspectorTable(); const rows = table.getRowModel().rows; const virtualizerOptions = { @@ -110,7 +111,7 @@ function Body({ rowHeight, children, after }: BodyProps) { const padTop = items[0]?.start ?? 0; const padBottom = virtualizer.getTotalSize() - (items.at(-1)?.end ?? 0); return ( - + {padTop > 0 && } {items.map(({ key, index }) => ( {children(rows[index])} diff --git a/ui/litellm-dashboard/src/components/shared/LoadingState.tsx b/ui/litellm-dashboard/src/components/shared/LoadingState.tsx new file mode 100644 index 00000000000..e4881602ca9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/LoadingState.tsx @@ -0,0 +1,17 @@ +import { Loader2 } from "lucide-react"; + +import { StateMessage, type StateMessageProps } from "./StateMessage"; + +type LoadingStateProps = Omit; + +export function LoadingState({ title, ...props }: LoadingStateProps) { + return ( + } + title={title} + {...props} + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/lens/ui/StateMessage.tsx b/ui/litellm-dashboard/src/components/shared/StateMessage.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/lens/ui/StateMessage.tsx rename to ui/litellm-dashboard/src/components/shared/StateMessage.tsx diff --git a/ui/litellm-dashboard/src/components/shared/search/SearchBox.test.tsx b/ui/litellm-dashboard/src/components/shared/search/SearchBox.test.tsx index 1e0e6090d75..a46c85f9b54 100644 --- a/ui/litellm-dashboard/src/components/shared/search/SearchBox.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/search/SearchBox.test.tsx @@ -117,6 +117,24 @@ afterEach(() => { }); describe("SearchBox", () => { + it("swaps the search icon for a loading status while results for the query load", () => { + const search = (busy: boolean) => ( + + + + ); + const { rerender } = render(search(true)); + expect(screen.getByRole("status", { name: "Loading results" })).toBeVisible(); + rerender(search(false)); + expect(screen.queryByRole("status", { name: "Loading results" })).not.toBeInTheDocument(); + }); + it("builds a filter from the keyboard: field, then value", async () => { const user = userEvent.setup(); render(); diff --git a/ui/litellm-dashboard/src/components/shared/search/SearchBox.tsx b/ui/litellm-dashboard/src/components/shared/search/SearchBox.tsx index 32dde73e728..4e45f8902f0 100644 --- a/ui/litellm-dashboard/src/components/shared/search/SearchBox.tsx +++ b/ui/litellm-dashboard/src/components/shared/search/SearchBox.tsx @@ -4,7 +4,7 @@ import "prosemirror-view/style/prosemirror.css"; import { useDebouncer } from "@tanstack/react-pacer/debouncer"; import { ProseMirror, ProseMirrorDoc, reactKeys, useEditorEventCallback } from "@handlewithcare/react-prosemirror"; -import { Check, Copy, CornerDownLeft, type LucideIcon, Search } from "lucide-react"; +import { Check, Copy, CornerDownLeft, Loader2, type LucideIcon, Search } from "lucide-react"; import { Schema } from "prosemirror-model"; import { EditorState, Plugin, TextSelection, type Transaction } from "prosemirror-state"; import { Decoration, DecorationSet, type EditorView } from "prosemirror-view"; @@ -232,10 +232,12 @@ function Root({ export type SearchBoxInputProps = ComponentProps<"div"> & { placeholder: string; + /** Results for the current query are loading: the search icon becomes a spinner. */ + busy?: boolean; }; /** The bordered field holding the editor; shows `placeholder` while the query is empty. */ -function Input({ placeholder, className, ...props }: SearchBoxInputProps) { +function Input({ placeholder, busy = false, className, ...props }: SearchBoxInputProps) { const { text } = useSearchBox(); return (
- + {busy ? ( + + ) : ( + + )}
{!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() { <>