From 5bb6a79a9b04a215e80577100d7fe86cda120d8a Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sun, 4 Oct 2026 17:00:17 -0700 Subject: [PATCH] feat(ui): sort Lens runs by column on the server Time, Steps, Duration and Failed headers now set a run order that the list sends to /v1/traces as sort_by and sort_dir, next to the existing window, search and cursor. The order is its own read axis: TracesApi.list takes selection, order and page bags, the hook restarts paging from the first page when the order changes, and the order lives in the URL beside q, so links keep it. Cost and Agents stay unsortable because the server computes them after the page is cut. While a new order loads the previous rows stay visible and dimmed under a progress line, auto-paging pauses, and the list scrolls back to the top. Headers carry aria-sort. The copied trace query and the demo follow the same order. The proxy ignores the new query parameters until the matching backend change lands, so until then every order still arrives newest first. --- .../lens/data/demo/createLensDemo.test.ts | 11 +- .../lens/data/demo/createLensDemo.ts | 8 +- .../src/components/lens/traces/api.ts | 28 +++-- .../AgentTracesSection.integration.test.tsx | 64 +++++++++-- .../lens/traces/list/AgentTracesSection.tsx | 19 +++- .../traces/list/AgentTracesTable.test.tsx | 90 +++++++++++++++ .../lens/traces/list/AgentTracesTable.tsx | 104 +++++++++++++----- .../lens/traces/list/runOrder.test.ts | 77 +++++++++++++ .../components/lens/traces/list/runOrder.ts | 53 +++++++++ .../lens/traces/list/runSearch/RunSearch.tsx | 8 +- .../traces/list/runSearch/RunsToolbar.tsx | 8 +- .../lens/traces/list/runSearch/runSql.test.ts | 16 ++- .../lens/traces/list/runSearch/runSql.ts | 27 ++++- .../lens/traces/list/useAgentTraces.ts | 35 ++++-- .../lens/traces/list/useTraceHistogram.ts | 12 +- .../components/lens/traces/routing.test.tsx | 53 +++++++++ .../src/components/lens/traces/routing.ts | 19 +++- .../src/components/networking.tsx | 27 +++-- .../src/components/shared/InspectorTable.tsx | 35 +++++- ui/litellm-dashboard/tests/setupTests.ts | 3 + 20 files changed, 598 insertions(+), 99 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/lens/traces/list/runOrder.test.ts create mode 100644 ui/litellm-dashboard/src/components/lens/traces/list/runOrder.ts create mode 100644 ui/litellm-dashboard/src/components/lens/traces/routing.test.tsx diff --git a/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.test.ts b/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.test.ts index bc042572a1d..ecc378eb4bf 100644 --- a/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.test.ts +++ b/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.test.ts @@ -3,6 +3,7 @@ import { createLensDemo, demoHistogram } from "./createLensDemo"; import { createLensDemoData } from "./fixtures"; import { evidenceTarget } from "../../model/findings"; import type { TraceSummary } from "../../traces/types"; +import { NEWEST } from "@/components/lens/traces/list/runOrder"; const HOUR = 3600 * 1000; const START = Date.UTC(2026, 8, 30, 0, 0, 0); @@ -102,8 +103,14 @@ describe("Lens demo data", () => { const network = vi.spyOn(globalThis, "fetch"); const now = Date.now(); const services = createLensDemo(now); - const all = await services.traces.list({ startMs: 0, endMs: now + 1, q: "" }); - const recent = await services.traces.list({ startMs: now - 3600_000, endMs: now + 1, q: "" }); + const listWithin = (startMs: number) => + services.traces.list({ + selection: { window: { startMs, endMs: now + 1 }, q: "" }, + order: NEWEST, + page: { cursor: null }, + }); + const all = await listWithin(0); + const recent = await listWithin(now - 3600_000); expect(recent.data.length).toBeGreaterThan(0); expect(recent.data.length).toBeLessThan(all.data.length); expect(recent.data.every((trace) => Date.parse(trace.start_time) >= now - 3600_000)).toBe(true); 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 acf93fa481b..f8ae66c8f97 100644 --- a/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.ts +++ b/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.ts @@ -1,6 +1,7 @@ import { ApiError } from "@/lib/http/client"; import type { TracesApi } from "@/components/lens/traces/api"; import type { TimeWindow } from "@/components/shared/timeRange/timeRange"; +import { orderRuns } from "@/components/lens/traces/list/runOrder"; 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"; @@ -89,8 +90,11 @@ function demoTracesApi(data: LensDemoData): TracesApi { const step = spanId ? found?.details.find((span) => span.span_id === spanId) : found; return { text: JSON.stringify(step, null, 2), copied: spanId ? "Step copied" : "Trace copied" }; }, - list: async ({ startMs, endMs, q }) => ({ data: matching({ startMs, endMs }, q), next_cursor: null }), - histogram: async (range, q, buckets) => demoHistogram(matching(range, q), range, buckets), + list: async ({ selection, order }) => ({ + data: orderRuns(matching(selection.window, selection.q), order), + next_cursor: null, + }), + histogram: async ({ window, q }, buckets) => demoHistogram(matching(window, q), window, buckets), values: async (field, contains, range) => { const read = field in RUN_INDEX.read ? RUN_INDEX.read[field as keyof typeof RUN_INDEX.read] : null; if (!read) return []; diff --git a/ui/litellm-dashboard/src/components/lens/traces/api.ts b/ui/litellm-dashboard/src/components/lens/traces/api.ts index 61fe4a8c275..d00a27f50c1 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/api.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/api.ts @@ -10,15 +10,25 @@ import { getProxyBaseUrl, } from "../../networking"; import type { TimeWindow } from "@/components/shared/timeRange/timeRange"; +import type { RunOrder } from "./list/runOrder"; import type { RunField, SpanDetail, SpanErrorPage, Trace, TraceHistogram, TracePage } from "./types"; -export interface TraceWindow extends TimeWindow { - readonly cursor?: string | null; +/** Which runs: the window and the `q` search the server applies before counting or paging. */ +export interface RunSelection { + readonly window: TimeWindow; + readonly q: string; } -/** One page of the runs list: the window, the `q` search the server applies, and where to resume. */ -export interface RunListRequest extends TraceWindow { - readonly q: string; +/** Where in the ordered sequence a page starts; `null` is the first page. */ +export interface RunPage { + readonly cursor: string | null; +} + +/** One page of the runs list, as the server's independent read axes. */ +export interface RunListRequest { + readonly selection: RunSelection; + readonly order: RunOrder; + readonly page: RunPage; } export interface TraceHandoff { @@ -33,7 +43,7 @@ export interface TracesApi { readonly live: boolean; handoff(traceId: string, spanId?: string | null, traceRef?: string): TraceHandoff; list(request: RunListRequest): Promise; - histogram(range: TimeWindow, q: string, buckets: number): Promise; + histogram(selection: RunSelection, buckets: number): Promise; values(field: RunField, contains: string, range: TimeWindow): Promise; anyRecorded(): Promise; trace(traceId: string, traceRef?: string, cursor?: string | null): Promise; @@ -60,11 +70,11 @@ export function liveTracesApi(accessToken: string): TracesApi { text: agentHandoffText(traceId, spanId, traceRef), copied: "Command copied", }), - list: (request) => agentTraceListCall({ accessToken, ...request }), - histogram: (range, q, buckets) => + list: (request) => agentTraceListCall(accessToken, request), + histogram: ({ window, q }, buckets) => apiClient.get("/v1/traces/histogram", { accessToken, - query: { start_ms: range.startMs, end_ms: range.endMs, q: q || undefined, buckets }, + query: { start_ms: window.startMs, end_ms: window.endMs, q: q || undefined, buckets }, }), values: async (field, contains, range) => { const found = await apiClient.get<{ values: string[] }>(`/v1/traces/values/${field}`, { 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 e1cf8b49dde..259c44a294b 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,6 +9,8 @@ import { renderWithProviders, testQueryClient } from "../../../../../tests/test- import traceList from "../__fixtures__/trace_list.json"; import { LensPreviewContext } from "../../ui/LensPreviewButton"; import AgentTracesPage from "./AgentTracesPage"; +import { previewText } from "../utils"; +import { NEWEST, orderRuns, type RunOrder } from "./runOrder"; import { filterRuns } from "./runSearch/runQuery"; import { demoHistogram } from "../../data/demo/createLensDemo"; import { AgentTracesSection, type TimeControls } from "./AgentTracesSection"; @@ -51,8 +53,8 @@ const serve = (data: readonly TraceSummary[]) => { data.filter((run) => startedWithin(run, startMs, endMs)), q, ); - vi.mocked(agentTraceListCall).mockImplementation(async ({ startMs, endMs, q }) => ({ - data: matching(startMs, endMs, q), + vi.mocked(agentTraceListCall).mockImplementation(async (_token, { selection, order }) => ({ + data: orderRuns(matching(selection.window.startMs, selection.window.endMs, selection.q), order), next_cursor: null, })); vi.mocked(apiClient.get).mockImplementation(async (path: string, options?: { query?: Record }) => { @@ -66,6 +68,9 @@ const serve = (data: readonly TraceSummary[]) => { const lastUrl = (onUrlUpdate: ReturnType) => new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? "")); +const listRequest = (index: number) => vi.mocked(agentTraceListCall).mock.calls.at(index)?.[1]; +const lastWindow = () => listRequest(-1)?.selection.window; + /** 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. @@ -121,7 +126,7 @@ describe("AgentTracesSection", () => { expect(agentTraceListCall).toHaveBeenCalledOnce(); act(() => mockAllIsIntersecting(true)); await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(2)); - expect(vi.mocked(agentTraceListCall).mock.calls[1][0]).toMatchObject({ cursor: "next" }); + expect(listRequest(1)).toMatchObject({ page: { cursor: "next" } }); expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument(); expect(agentTraceListCall).toHaveBeenCalledTimes(2); }); @@ -132,7 +137,7 @@ describe("AgentTracesSection", () => { vi.mocked(agentTraceListCall).mockResolvedValueOnce({ data: runs.slice(0, 1), next_cursor: "next" }); renderSection(); expect(await screen.findByTestId("agent-trace-row")).toBeVisible(); - const first = vi.mocked(agentTraceListCall).mock.calls[0][0]; + const first = listRequest(0); now.mockReturnValue(Date.parse("2026-10-01T01:00Z")); vi.mocked(agentTraceListCall).mockRejectedValue(new ApiError("Please try again", 403, {})); act(() => mockAllIsIntersecting(true)); @@ -140,7 +145,7 @@ describe("AgentTracesSection", () => { expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1); expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument(); expect(agentTraceListCall).toHaveBeenCalledTimes(2); - expect(vi.mocked(agentTraceListCall).mock.calls[1][0]).toEqual({ ...first, cursor: "next" }); + expect(listRequest(1)).toEqual({ ...first, page: { cursor: "next" } }); vi.mocked(agentTraceListCall).mockResolvedValueOnce({ data: runs.slice(1, 2), next_cursor: null }); await user.click(screen.getByRole("button", { name: "Retry" })); await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(2)); @@ -595,7 +600,7 @@ describe("AgentTracesPage", () => { fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last 7 days" })); expect(trigger).toHaveTextContent("Last 7 days"); await waitFor(() => { - const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0]; + const last = lastWindow(); expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000); }); @@ -604,7 +609,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)); + await waitFor(() => expect(lastWindow()?.endMs).toBe(pausedAt)); }); it("keeps the time controls on an empty range the user picked, instead of showing onboarding", async () => { @@ -626,24 +631,63 @@ describe("AgentTracesPage", () => { const onUrlUpdate = vi.fn(); renderWithProviders(, { searchParams: "?hours=168", onUrlUpdate }); await screen.findByTestId("runs-table"); - const { startMs, endMs } = vi.mocked(agentTraceListCall).mock.calls[0][0]; + const { startMs, endMs } = listRequest(0)!.selection.window; expect(endMs - startMs).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000); fireEvent.click(screen.getByRole("button", { name: "Time range" })); fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last hour" })); await waitFor(() => { - const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0]; + const last = lastWindow(); expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeLessThanOrEqual(3600 * 1000 + 60_000); }); await waitFor(() => expect(lastUrl(onUrlUpdate).get("hours")).toBe("1")); }); + it("re-sorts on the server from the first page, keeps the rows meanwhile, and remembers the order in the URL", async () => { + pinNowToFixtures(); + serve(runs); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); + const rowsBefore = await screen.findAllByTestId("agent-trace-row"); + expect(listRequest(0)).toMatchObject({ order: { key: "start_ms", descending: true }, page: { cursor: null } }); + const firstInput = (order: RunOrder) => previewText(orderRuns(runs, order)[0].input_preview).split("\n")[0]; + // A first click sorts descending; pick a key whose top run is not already the newest one. + const key = (["duration_ms", "span_count", "error_count"] as const).find( + (candidate) => firstInput({ key: candidate, descending: true }) !== firstInput(NEWEST), + ); + if (key === undefined) throw new Error("fixtures need a run that leads a non-time order"); + const topAfter = firstInput({ key, descending: true }); + expect(rowsBefore[0]).not.toHaveTextContent(topAfter); + + let release = () => {}; + vi.mocked(agentTraceListCall).mockImplementationOnce( + (_token, { selection, order }) => + new Promise((resolve) => { + release = () => + resolve({ + data: orderRuns(filterRuns(runs, selection.q), order), + next_cursor: null, + }); + }), + ); + fireEvent.click(screen.getByTestId(`sort-header-${key}`)); + expect(await screen.findByRole("progressbar", { name: "Updating rows" })).toBeInTheDocument(); + expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(rowsBefore.length); + expect(listRequest(-1)).toMatchObject({ order: { key, descending: true }, page: { cursor: null } }); + + act(release); + await waitFor(() => expect(screen.queryByRole("progressbar")).not.toBeInTheDocument()); + expect(screen.getAllByTestId("agent-trace-row")[0]).toHaveTextContent(topAfter); + expect(lastUrl(onUrlUpdate).get("sort_by")).toBe(key); + expect(lastUrl(onUrlUpdate).has("sort_dir")).toBe(false); + }); + it("asks the proxy for the last 24 hours by default", async () => { vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); renderWithProviders(); await screen.findByTestId("runs-table"); - const { startMs, endMs } = vi.mocked(agentTraceListCall).mock.calls[0][0]; + const { startMs, endMs } = listRequest(0)!.selection.window; expect(endMs - startMs).toBeGreaterThanOrEqual(24 * 3600 * 1000 - 60_000); expect(endMs - startMs).toBeLessThan(24 * 3600 * 1000 + 120_000); }); 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 e8b33e3d816..8a6331062bc 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx @@ -8,7 +8,14 @@ import { Inspector } from "@/components/shared/Inspector"; import { Button } from "@/components/ui/button"; import { AgentTracesTable } from "./AgentTracesTable"; -import { type TraceRef, traceKey, traceRefOf, useOpenTraceRouting, useRunFilterRouting } from "../routing"; +import { + type TraceRef, + traceKey, + traceRefOf, + useOpenTraceRouting, + useRunFilterRouting, + useRunOrderRouting, +} from "../routing"; import type { TraceSummary } from "../types"; import { RunView } from "../detail/run/RunView"; import { useZoomRouting } from "@/components/shared/timeRange/routing"; @@ -75,10 +82,11 @@ export function AgentTracesSection({ const live = useTracesLive(); const { trace: openTrace, openTrace: openRun, selection, fullScreen, setFullScreen } = useOpenTraceRouting(); const { query, setQuery } = useRunFilterRouting(); + const [order, setOrder] = useRunOrderRouting(); const [showSetup, setShowSetup] = useState(false); const [zoom, setZoom] = useZoomRouting(); const [rangeChanged, setRangeChanged] = useState(false); - const traceQuery = { accessToken, range, enabled: isActive, q: query, zoom }; + const traceQuery = { accessToken, range, enabled: isActive, q: query, zoom, order }; const traces = useAgentTraces(traceQuery); const narrowed = rangeChanged || zoom !== null || query.trim() !== ""; const setup = useTracingSetup(traces, isActive, narrowed); @@ -93,7 +101,7 @@ export function AgentTracesSection({ // 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 histogram = useTraceHistogram(accessToken, { window, q: query }, isActive); const runs = traces.traces; const runRefs = useMemo(() => runs.map(traceRefOf), [runs]); @@ -163,7 +171,7 @@ export function AgentTracesSection({ /> )} - + {timeControls && ( { expect(headers()).toHaveLength(before - 1); }); }); + +describe("AgentTracesTable sorting", () => { + beforeEach(() => localStorage.clear()); + const runs = (traceList as TracePage).data as TraceSummary[]; + const sortable = (order: RunOrder, onOrderChange = vi.fn()) => + inList( + , + ); + const header = (id: string) => screen.getByTestId(`sort-header-${id}`); + + it("asks the server for a column descending first, flips it on the next click, and never clears the order", () => { + const onOrderChange = vi.fn(); + const view = renderWithProviders(sortable(NEWEST, onOrderChange)); + fireEvent.click(header("duration_ms")); + expect(onOrderChange).toHaveBeenLastCalledWith({ key: "duration_ms", descending: true }); + + view.rerender(sortable({ key: "duration_ms", descending: true }, onOrderChange)); + fireEvent.click(header("duration_ms")); + expect(onOrderChange).toHaveBeenLastCalledWith({ key: "duration_ms", descending: false }); + + view.rerender(sortable({ key: "duration_ms", descending: false }, onOrderChange)); + fireEvent.click(header("duration_ms")); + expect(onOrderChange).toHaveBeenLastCalledWith({ key: "duration_ms", descending: true }); + expect(onOrderChange).toHaveBeenCalledTimes(3); + }); + + it("exposes the active order on the column header and offers no sort on values the server cannot order by", () => { + renderWithProviders(sortable({ key: "error_count", descending: false })); + const headers = screen.getAllByRole("columnheader"); + const byName = (name: RegExp) => headers.find((cell) => name.test(cell.textContent ?? "")); + expect(byName(/Failed/)).toHaveAttribute("aria-sort", "ascending"); + expect(byName(/Time/)).toHaveAttribute("aria-sort", "none"); + expect(byName(/Cost/)).not.toHaveAttribute("aria-sort"); + expect(within(byName(/Cost/) as HTMLElement).queryByRole("button")).not.toBeInTheDocument(); + expect(within(byName(/Agents/) as HTMLElement).queryByRole("button")).not.toBeInTheDocument(); + expect(screen.queryByTestId("sort-header-cost")).not.toBeInTheDocument(); + }); + + it("renders plain headings when the order cannot be changed", () => { + renderWithProviders( + inList( + , + ), + ); + expect(screen.queryByTestId("sort-header-start_ms")).not.toBeInTheDocument(); + expect(screen.getAllByRole("columnheader").some((cell) => cell.hasAttribute("aria-sort"))).toBe(false); + }); + + it("keeps the previous rows visible and pauses paging while a new order loads", () => { + const onLoadMore = vi.fn(); + renderWithProviders( + inList( + , + ), + ); + expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length); + expect(screen.getByRole("progressbar", { name: "Updating rows" })).toBeInTheDocument(); + expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(onLoadMore).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.tsx index 541d92550e9..d712bbe084e 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.tsx @@ -1,15 +1,23 @@ "use client"; -import { getCoreRowModel, useReactTable, type ColumnDef, type TableOptions } from "@tanstack/react-table"; -import { ArrowDown, ChevronRight } from "lucide-react"; -import { useEffect } from "react"; +import { + getCoreRowModel, + useReactTable, + type ColumnDef, + type HeaderContext, + type TableOptions, +} from "@tanstack/react-table"; +import { ChevronRight } from "lucide-react"; +import { useEffect, useMemo, type ReactNode } from "react"; import { useInView } from "react-intersection-observer"; +import { DataTableSortHeader } from "@/components/shared/DataTable/DataTableSortHeader"; import { DataTableViewOptions } from "@/components/shared/DataTable/DataTableViewOptions"; import { usePersistedColumnVisibility } from "@/components/shared/DataTable/usePersistedColumnVisibility"; import { InspectorTable, useInspectorTable } from "@/components/shared/InspectorTable"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/cva.config"; import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation } from "@/utils/activityTimestamp"; import { SpanIcon } from "../ui/SpanIcon"; @@ -18,6 +26,7 @@ import { FrameworkLogo, traceFramework } from "../ui/TraceFramework"; import type { TraceSummary } from "../types"; import { traceRefOf } from "../routing"; import { fmtMs, previewText, traceDisplayName, traceAgentNames } from "../utils"; +import { fromSorting, NEWEST, type RunOrder, toSorting } from "./runOrder"; interface AgentTracesTableProps { traces: TraceSummary[]; @@ -25,6 +34,11 @@ interface AgentTracesTableProps { error: Error | null; hasMore: boolean; isFetching?: boolean; + /** Rows belong to the previous order, search or window while this one loads. */ + isPlaceholder?: boolean; + /** The order rows arrive in; with `onOrderChange`, the sortable headers change it on the server. */ + order?: RunOrder; + onOrderChange?: (order: RunOrder) => void; onRetry?: () => void; onLoadMore: () => void; rangeEmpty?: boolean; @@ -79,19 +93,32 @@ function InputCell({ run }: { run: TraceSummary }) { ); } +const sortHeader = (title: ReactNode) => + function SortHeaderCell({ column }: HeaderContext) { + return ( + + ); + }; + +const TIME_TITLE = ( + + Time + {localTimeZoneAbbreviation()} + +); + const RUN_COLUMNS: ColumnDef[] = [ { - id: "time", + id: "start_ms", + accessorKey: "start_time", + enableSorting: true, size: 170, enableHiding: false, - header: () => ( - - Time - - {localTimeZoneAbbreviation()} - - - ), + header: sortHeader(TIME_TITLE), cell: ({ row }) => ( {formatRunTimestamp(row.original.start_time)} @@ -127,18 +154,22 @@ const RUN_COLUMNS: ColumnDef[] = [ meta: { numeric: true, className: MUTED_NUM }, }, { - id: "steps", + id: "span_count", + accessorKey: "span_count", + enableSorting: true, size: 74, - header: "Steps", + header: sortHeader("Steps"), cell: ({ row }) => row.original.span_count.toLocaleString(), - meta: { numeric: true, className: MUTED_NUM }, + meta: { title: "Steps", numeric: true, className: MUTED_NUM }, }, { - id: "duration", + id: "duration_ms", + accessorKey: "duration_ms", + enableSorting: true, size: 86, - header: "Duration", + header: sortHeader("Duration"), cell: ({ row }) => fmtMs(row.original.duration_ms), - meta: { numeric: true, className: NUM }, + meta: { title: "Duration", numeric: true, className: NUM }, }, { id: "cost", @@ -148,16 +179,18 @@ const RUN_COLUMNS: ColumnDef[] = [ meta: { numeric: true, className: NUM }, }, { - id: "failed", + id: "error_count", + accessorKey: "error_count", + enableSorting: true, size: 72, - header: "Failed", + header: sortHeader("Failed"), cell: ({ row }) => row.original.error_count > 0 ? ( ) : ( 0 ), - meta: { numeric: true }, + meta: { title: "Failed", numeric: true }, }, { id: "open", @@ -184,6 +217,15 @@ function LoadMoreRows({ isFetching, onLoadMore }: { isFetching: boolean; onLoadM )); } +/** A new order starts at its first row, wherever the previous order had been scrolled to. */ +function ScrollToTop({ order }: { order: RunOrder }) { + const { scroller } = useInspectorTable(); + useEffect(() => { + scroller?.scrollTo({ top: 0 }); + }, [scroller, order.key, order.descending]); + return null; +} + function EmptyRuns({ rangeEmpty, onSetUpTracing }: { rangeEmpty: boolean; onSetUpTracing: () => void }) { if (!rangeEmpty) return ( @@ -205,13 +247,16 @@ function EmptyRuns({ rangeEmpty, onSetUpTracing }: { rangeEmpty: boolean; onSetU ); } -/** Devtool-dense runs list: one row per agent run, newest first. */ +/** Devtool-dense runs list: one row per agent run, in the server's order. */ export function AgentTracesTable({ traces, isLoading, error, hasMore, isFetching = false, + isPlaceholder = false, + order = NEWEST, + onOrderChange, onRetry, onLoadMore, rangeEmpty = false, @@ -219,24 +264,33 @@ export function AgentTracesTable({ }: AgentTracesTableProps) { const settled = !isLoading && !error; const isEmpty = settled && !hasMore && traces.length === 0; - const autoContinue = settled && hasMore && traces.length > 0; + const autoContinue = settled && hasMore && traces.length > 0 && !isPlaceholder; const { columnVisibility, onColumnVisibilityChange } = usePersistedColumnVisibility("lens-traces"); + const sorting = useMemo(() => toSorting(order), [order]); const tableOptions: TableOptions = { data: traces, columns: RUN_COLUMNS, getRowId: runKey, autoResetAll: false, - state: { columnVisibility }, + manualSorting: true, + enableSorting: onOrderChange !== undefined, + enableMultiSort: false, + enableSortingRemoval: false, + sortDescFirst: true, + state: { columnVisibility, sorting }, onColumnVisibilityChange, + onSortingChange: (updater) => onOrderChange?.(fromSorting(updater, order)), getCoreRowModel: getCoreRowModel(), }; const table = useReactTable(tableOptions); return ( + - + rowHeight={() => ROW_HEIGHT} + className={cn(isPlaceholder && "opacity-60 transition-opacity motion-reduce:transition-none")} after={ <> {isLoading && SKELETON_ROWS.map((row) => )} diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/runOrder.test.ts b/ui/litellm-dashboard/src/components/lens/traces/list/runOrder.test.ts new file mode 100644 index 00000000000..0c827309a19 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/traces/list/runOrder.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import traceList from "../__fixtures__/trace_list.json"; +import { fromSorting, NEWEST, orderRuns, toSorting, type RunOrder } from "./runOrder"; +import type { TracePage, TraceSummary } from "../types"; + +const template = (traceList as TracePage).data[0] as TraceSummary; +const run = (overrides: Partial): TraceSummary => ({ ...template, ...overrides }); + +const RUNS: TraceSummary[] = [ + run({ + trace_id: "a", + trace_ref: "ref-a", + start_time: "2026-09-30T06:00:00Z", + duration_ms: 500, + span_count: 3, + error_count: 0, + }), + run({ + trace_id: "b", + trace_ref: "ref-b", + start_time: "2026-09-30T07:00:00Z", + duration_ms: 500, + span_count: 9, + error_count: 2, + }), + run({ + trace_id: "c", + trace_ref: "ref-c", + start_time: "2026-09-30T05:00:00Z", + duration_ms: 50, + span_count: 1, + error_count: 1, + }), +]; + +const ids = (runs: readonly TraceSummary[]) => runs.map((item) => item.trace_id); + +describe("orderRuns", () => { + it.each<{ order: RunOrder; expected: string[] }>([ + { order: NEWEST, expected: ["b", "a", "c"] }, + { order: { key: "start_ms", descending: false }, expected: ["c", "a", "b"] }, + { order: { key: "span_count", descending: true }, expected: ["b", "a", "c"] }, + { order: { key: "error_count", descending: false }, expected: ["a", "c", "b"] }, + ])("orders by $order.key with descending=$order.descending", ({ order, expected }) => { + expect(ids(orderRuns(RUNS, order))).toEqual(expected); + }); + + it("breaks a tie on the trace reference in the same direction, so pages never overlap", () => { + expect(ids(orderRuns(RUNS, { key: "duration_ms", descending: true }))).toEqual(["b", "a", "c"]); + expect(ids(orderRuns(RUNS, { key: "duration_ms", descending: false }))).toEqual(["c", "a", "b"]); + }); + + it("leaves the input untouched", () => { + const before = ids(RUNS); + orderRuns(RUNS, { key: "span_count", descending: false }); + expect(ids(RUNS)).toEqual(before); + }); +}); + +describe("sorting state round trip", () => { + it("maps an order to one sorting entry and back", () => { + const order: RunOrder = { key: "duration_ms", descending: false }; + expect(toSorting(order)).toEqual([{ id: "duration_ms", desc: false }]); + expect(fromSorting(toSorting(order), NEWEST)).toEqual(order); + }); + + it("applies a functional updater against the current order", () => { + const flipped = fromSorting((previous) => previous.map((entry) => ({ ...entry, desc: !entry.desc })), NEWEST); + expect(flipped).toEqual({ key: "start_ms", descending: false }); + }); + + it("keeps the current order when the state is empty or names a column the server cannot sort", () => { + expect(fromSorting([], NEWEST)).toEqual(NEWEST); + expect(fromSorting([{ id: "cost", desc: true }], NEWEST)).toEqual(NEWEST); + }); +}); diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/runOrder.ts b/ui/litellm-dashboard/src/components/lens/traces/list/runOrder.ts new file mode 100644 index 00000000000..5772ad3828f --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/traces/list/runOrder.ts @@ -0,0 +1,53 @@ +import { functionalUpdate, type SortingState, type Updater } from "@tanstack/react-table"; + +import type { TraceSummary } from "../types"; + +export const RUN_SORT_KEYS = ["start_ms", "duration_ms", "span_count", "error_count"] as const; +export type RunSortKey = (typeof RUN_SORT_KEYS)[number]; + +export const SORT_DIRS = ["asc", "desc"] as const; +export type SortDir = (typeof SORT_DIRS)[number]; + +/** The sequence a runs page is cut from: one server-sorted key, with the trace reference as the tie-break. */ +export interface RunOrder { + readonly key: RunSortKey; + readonly descending: boolean; +} + +export const NEWEST: RunOrder = { key: "start_ms", descending: true }; + +export const sortDir = (order: RunOrder): SortDir => (order.descending ? "desc" : "asc"); + +const SORT_VALUE: Record number> = { + start_ms: (run) => Date.parse(run.start_time), + duration_ms: (run) => run.duration_ms, + span_count: (run) => run.span_count, + error_count: (run) => run.error_count, +}; + +const compareText = (left: string, right: string): number => { + if (left === right) return 0; + return left < right ? -1 : 1; +}; + +const reference = (run: TraceSummary): string => run.trace_ref || run.trace_id; + +/** Runs in `order`, as the server pages them: by the key, then by trace reference the same way. */ +export function orderRuns(runs: readonly TraceSummary[], order: RunOrder): TraceSummary[] { + const value = SORT_VALUE[order.key]; + const sign = order.descending ? -1 : 1; + return [...runs].sort((left, right) => { + const byKey = value(left) - value(right); + return sign * (byKey !== 0 ? byKey : compareText(reference(left), reference(right))); + }); +} + +export const toSorting = (order: RunOrder): SortingState => [{ id: order.key, desc: order.descending }]; + +const isSortKey = (id: string): id is RunSortKey => RUN_SORT_KEYS.includes(id as RunSortKey); + +/** The order TanStack's sorting state asks for; an empty or unknown state keeps `current`. */ +export function fromSorting(updater: Updater, current: RunOrder): RunOrder { + const [first] = functionalUpdate(updater, toSorting(current)); + return first !== undefined && isSortKey(first.id) ? { key: first.id, descending: first.desc } : current; +} 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 fa6b4a6a87b..3f713fd2cd8 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 @@ -7,6 +7,7 @@ import type { TimeWindow } from "@/components/shared/timeRange/timeRange"; import { SearchBox } from "@/components/shared/search/SearchBox"; import { itemValues } from "@/components/shared/search/valueSource"; +import { NEWEST, type RunOrder } from "../runOrder"; import { RUN_INDEX, RUN_QUERY } from "./runQuery"; import { runQueryCommand } from "./runSql"; @@ -17,13 +18,14 @@ interface RunSearchProps { onChange: (value: string) => void; /** Loaded runs, the source of value suggestions. */ runs: readonly TraceSummary[]; - /** The range the list shows; the copied query bounds itself to it. */ + /** The range and order the list shows; the copied query follows both. */ range?: TimeWindow; + order?: RunOrder; } /** 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) { - const command = useMemo(() => runQueryCommand(range), [range]); +export function RunSearch({ value, onChange, runs, range, order = NEWEST }: RunSearchProps) { + const command = useMemo(() => runQueryCommand(range, order), [range, order]); return ( void; runs: readonly TraceSummary[]; - /** The range the list shows, for the copied query. */ + /** The range and order the list shows, for the copied query. */ range?: TimeWindow; + order?: RunOrder; /** 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, order, children }: RunsToolbarProps) { return (
- + {children &&
{children}
}
); diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.test.ts b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.test.ts index faae0491fbe..2babd8302e6 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.test.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.test.ts @@ -4,6 +4,7 @@ import { parseQuery } from "@/components/shared/search/language"; import { toSearchQuery } from "@/components/shared/search/searchQuery"; import { RUN_QUERY } from "./runQuery"; import { runPredicates, runQueryCommand, runQuerySql, traceQueryCommand } from "./runSql"; +import type { RunOrder } from "../runOrder"; const query = (text: string) => toSearchQuery(parseQuery(RUN_QUERY, text)); const predicates = (text: string) => runPredicates(query(text)); @@ -52,13 +53,14 @@ describe("runQuerySql", () => { expect(sql).toBe( [ "SELECT TraceId AS trace_id, any(RootName) AS name, any(RootInput) AS input, sum(ErrorCount) AS errors,", - " groupUniqArrayArray(AgentNames) AS agents, groupUniqArrayArray(Models) AS models", + " groupUniqArrayArray(AgentNames) AS agents, groupUniqArrayArray(Models) AS models,", + " sum(SpanCount) AS steps, dateDiff('millisecond', min(StartTs), max(EndTs)) AS duration_ms", "FROM agent_traces_by_key", "GROUP BY TraceId", `HAVING min(StartTs) >= fromUnixTimestamp64Milli(${RANGE.startMs}) AND min(StartTs) < fromUnixTimestamp64Milli(${RANGE.endMs})`, " AND arrayExists(x -> x ILIKE 'researcher', agents)", " AND errors > 0", - "ORDER BY min(StartTs) DESC", + "ORDER BY min(StartTs) DESC, trace_id DESC", "LIMIT 100", ].join("\n"), ); @@ -67,6 +69,16 @@ describe("runQuerySql", () => { it("falls back to the last day without a range", () => { expect(runQuerySql(query(""))).toContain("HAVING min(StartTs) >= now() - INTERVAL 1 DAY\nORDER BY"); }); + + it.each<{ order: RunOrder; clause: string }>([ + { order: { key: "duration_ms", descending: false }, clause: "ORDER BY duration_ms ASC, trace_id ASC" }, + { order: { key: "span_count", descending: true }, clause: "ORDER BY steps DESC, trace_id DESC" }, + { order: { key: "error_count", descending: true }, clause: "ORDER BY errors DESC, trace_id DESC" }, + { order: { key: "start_ms", descending: false }, clause: "ORDER BY min(StartTs) ASC, trace_id ASC" }, + ])("orders the copied rows like the list, $clause", ({ order, clause }) => { + expect(runQuerySql(query(""), RANGE, order)).toContain(`\n${clause}\nLIMIT 100`); + expect(runQueryCommand(RANGE, order)(query(""))).toContain(clause); + }); }); describe("traceQueryCommand", () => { 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 b155945ddb5..f4daa04a2b6 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 @@ -3,13 +3,28 @@ 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"; +import { NEWEST, type RunOrder, type RunSortKey } from "../runOrder"; import type { RunField } from "./runQuery"; const RUN_ROWS = `SELECT TraceId AS trace_id, any(RootName) AS name, any(RootInput) AS input, sum(ErrorCount) AS errors, - groupUniqArrayArray(AgentNames) AS agents, groupUniqArrayArray(Models) AS models + groupUniqArrayArray(AgentNames) AS agents, groupUniqArrayArray(Models) AS models, + sum(SpanCount) AS steps, dateDiff('millisecond', min(StartTs), max(EndTs)) AS duration_ms FROM agent_traces_by_key GROUP BY TraceId`; +const ORDER_COLUMNS: Record = { + start_ms: "min(StartTs)", + duration_ms: "duration_ms", + span_count: "steps", + error_count: "errors", +}; + +/** The list's order with the same tie-break the server pages by. */ +const orderBy = (order: RunOrder): string => { + const direction = order.descending ? "DESC" : "ASC"; + return `ORDER BY ${ORDER_COLUMNS[order.key]} ${direction}, trace_id ${direction}`; +}; + const sqlString = (value: string): string => `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; /** LIKE's own metacharacters match literally. */ @@ -60,10 +75,10 @@ export const runPredicates = (query: SearchQuery): string[] => [ ...query.filters.map(filterPredicate), ]; -/** The runs list as a trace query: one row per trace from the per-key rollup, filtered like the list. */ -export function runQuerySql(query: SearchQuery, range?: TimeWindow): string { +/** The runs list as a trace query: one row per trace from the per-key rollup, filtered and ordered like the list. */ +export function runQuerySql(query: SearchQuery, range?: TimeWindow, order: RunOrder = NEWEST): string { const having = [timeBound(range), ...runPredicates(query)].join("\n AND "); - return `${RUN_ROWS}\nHAVING ${having}\nORDER BY min(StartTs) DESC\nLIMIT 100`; + return `${RUN_ROWS}\nHAVING ${having}\n${orderBy(order)}\nLIMIT 100`; } /** Runs `sql` through the trace query API; the quoted heredoc keeps the SQL's own quotes intact. */ @@ -76,6 +91,6 @@ export const traceQueryCommand = (sql: string): string => ].join("\n"); export const runQueryCommand = - (range?: TimeWindow) => + (range?: TimeWindow, order: RunOrder = NEWEST) => (query: SearchQuery): string => - traceQueryCommand(runQuerySql(query, range)); + traceQueryCommand(runQuerySql(query, range, order)); 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 a17fbf9155d..4670130c2ac 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.ts @@ -12,10 +12,15 @@ import { } from "@/components/shared/timeRange/timeRange"; import type { TracePage, TraceSummary } from "../types"; -import type { TraceWindow } from "../api"; +import type { RunOrder } from "./runOrder"; interface LoadedTracePage extends TracePage { - window: TraceWindow; + window: TimeWindow; +} + +interface PagePosition { + readonly window: TimeWindow; + readonly cursor: string; } export const TRACING_NOT_ENABLED_STATUS = 501; @@ -46,12 +51,15 @@ interface UseAgentTracesOptions { q: string; /** A window inside the range that replaces it for the list. */ zoom: TimeWindow | null; + order: RunOrder; } export interface AgentTracesResult { traces: TraceSummary[]; isLoading: boolean; isFetching: boolean; + /** Rows belong to the previous order, search or window while this one loads. */ + isPlaceholder: boolean; /** Set when the proxy answered 501: tracing isn't configured. */ notEnabledDetail: string | null; error: Error | null; @@ -64,20 +72,30 @@ export interface AgentTracesResult { * 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, range, enabled, q, zoom }: UseAgentTracesOptions): AgentTracesResult { +export function useAgentTraces({ + accessToken, + range, + enabled, + q, + zoom, + order, +}: UseAgentTracesOptions): AgentTracesResult { const traces = useTracesApi(accessToken); const isLiveTail = isLive(range); const fetchPage = async (pageParam: unknown): Promise => { - const window = (pageParam as TraceWindow | null) ?? zoom ?? timeWindow(range, Date.now()); - return { ...(await traces.list({ ...window, q })), window }; + const position = pageParam as PagePosition | null; + const window = position?.window ?? zoom ?? timeWindow(range, Date.now()); + const selection = { window, q }; + const page = { cursor: position?.cursor ?? null }; + return { ...(await traces.list({ selection, order, page })), window }; }; const queryOptions: Parameters>[0] = { - queryKey: ["agentTraces", traces.scope, range.hours, range.anchorMs, q, zoom], + queryKey: ["agentTraces", traces.scope, range.hours, range.anchorMs, q, zoom, order.key, order.descending], placeholderData: keepPreviousData, queryFn: ({ pageParam }) => fetchPage(pageParam), initialPageParam: null, - getNextPageParam: (lastPage) => - lastPage.next_cursor ? { ...lastPage.window, cursor: lastPage.next_cursor } : undefined, + getNextPageParam: (lastPage): PagePosition | undefined => + lastPage.next_cursor ? { window: lastPage.window, cursor: lastPage.next_cursor } : undefined, enabled, staleTime: LIVE_TAIL_INTERVAL_MS, retry: (failureCount, error) => !requiresUserAction(error) && failureCount < 1, @@ -95,6 +113,7 @@ export function useAgentTraces({ accessToken, range, enabled, q, zoom }: UseAgen traces: loaded, isLoading: query.isLoading, isFetching: query.isFetching, + isPlaceholder: query.isPlaceholderData, 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/list/useTraceHistogram.ts b/ui/litellm-dashboard/src/components/lens/traces/list/useTraceHistogram.ts index 62a6f2f2bee..cc052b96b49 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/useTraceHistogram.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/list/useTraceHistogram.ts @@ -1,7 +1,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import type { TimeWindow } from "@/components/shared/timeRange/timeRange"; -import { useTracesApi } from "../api"; +import { type RunSelection, useTracesApi } from "../api"; import type { TraceHistogram } from "../types"; import { BUCKETS, type TimeBucket } from "@/components/shared/timeline/Timeline"; @@ -35,17 +35,17 @@ 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: TimeWindow, - q: string, + selection: RunSelection, enabled: boolean, ): TraceHistogramResult { const traces = useTracesApi(accessToken); + const { window, q } = selection; const histogram = useQuery({ - queryKey: ["agentTraceHistogram", traces.scope, range.startMs, range.endMs, q], - queryFn: () => traces.histogram(range, q, BUCKETS), + queryKey: ["agentTraceHistogram", traces.scope, window.startMs, window.endMs, q], + queryFn: () => traces.histogram(selection, BUCKETS), enabled, placeholderData: keepPreviousData, select: toBuckets, }); - return { buckets: histogram.data ?? emptyBuckets(range), isLoading: histogram.isLoading }; + return { buckets: histogram.data ?? emptyBuckets(window), isLoading: histogram.isLoading }; } diff --git a/ui/litellm-dashboard/src/components/lens/traces/routing.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/routing.test.tsx new file mode 100644 index 00000000000..5ebf7ffe34c --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/traces/routing.test.tsx @@ -0,0 +1,53 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import type { PropsWithChildren } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { NEWEST } from "./list/runOrder"; +import { useRunOrderRouting } from "./routing"; + +const renderRouting = (searchParams: string) => { + const onUrlUpdate = vi.fn(); + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ); + const hook = renderHook(() => useRunOrderRouting(), { wrapper }); + const lastUrl = () => new URLSearchParams(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""); + return { ...hook, lastUrl, onUrlUpdate }; +}; + +describe("useRunOrderRouting", () => { + it("opens newest first when the URL names no order", () => { + const { result } = renderRouting("?q=agent:research"); + expect(result.current[0]).toEqual(NEWEST); + }); + + it("reads the order the URL names", () => { + const { result } = renderRouting("?sort_by=duration_ms&sort_dir=asc"); + expect(result.current[0]).toEqual({ key: "duration_ms", descending: false }); + }); + + it("falls back to newest first on an order the server cannot page by", () => { + const { result } = renderRouting("?sort_by=cost&sort_dir=sideways"); + expect(result.current[0]).toEqual(NEWEST); + }); + + it("writes a chosen order to the URL, leaving out whatever matches the default, and drops it back out", async () => { + const { result, lastUrl } = renderRouting(""); + await act(async () => result.current[1]({ key: "error_count", descending: false })); + expect(result.current[0]).toEqual({ key: "error_count", descending: false }); + await waitFor(() => expect(lastUrl().get("sort_by")).toBe("error_count")); + expect(lastUrl().get("sort_dir")).toBe("asc"); + + await act(async () => result.current[1]({ key: "error_count", descending: true })); + await waitFor(() => expect(lastUrl().has("sort_dir")).toBe(false)); + expect(lastUrl().get("sort_by")).toBe("error_count"); + + await act(async () => result.current[1](NEWEST)); + expect(result.current[0]).toEqual(NEWEST); + await waitFor(() => expect(lastUrl().has("sort_by")).toBe(false)); + expect(lastUrl().has("sort_dir")).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/components/lens/traces/routing.ts b/ui/litellm-dashboard/src/components/lens/traces/routing.ts index 46f261a9140..0ada989e8ca 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/routing.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/routing.ts @@ -1,7 +1,8 @@ import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryState, useQueryStates } from "nuqs"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { TIME_RANGE_PARSERS } from "@/components/shared/timeRange/routing"; +import { NEWEST, RUN_SORT_KEYS, type RunOrder, SORT_DIRS, sortDir } from "./list/runOrder"; import type { TraceSummary } from "./types"; export const TRACE_VIEWS = ["steps", "conversation"] as const; @@ -48,6 +49,11 @@ export const RUN_FILTER_PARSERS = { ...TIME_RANGE_PARSERS, }; +export const RUN_ORDER_PARSERS = { + sort_by: parseAsStringLiteral(RUN_SORT_KEYS).withDefault(NEWEST.key), + sort_dir: parseAsStringLiteral(SORT_DIRS).withDefault(sortDir(NEWEST)), +}; + export interface OpenTraceRouting { trace: TraceRef | null; openTrace: (ref: TraceRef | null) => void; @@ -127,3 +133,14 @@ 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]) }; } + +/** The order the runs list is cut in. Newest first is the default and stays out of the URL. */ +export function useRunOrderRouting(): [RunOrder, (order: RunOrder) => void] { + const [{ sort_by, sort_dir }, setParams] = useQueryStates(RUN_ORDER_PARSERS); + const order = useMemo(() => ({ key: sort_by, descending: sort_dir === "desc" }), [sort_by, sort_dir]); + const setOrder = useCallback( + (next: RunOrder) => void setParams({ sort_by: next.key, sort_dir: sortDir(next) }), + [setParams], + ); + return [order, setOrder]; +} diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 92549b0a5c0..441e1d55d61 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -117,6 +117,7 @@ import type { ComplexityRouterConfigPayload } from "./add_model/build_complexity import type { AutoRouterPresetsResponse } from "@/lib/autorouter_presets"; import type { VectorStoreIndex } from "@/app/(dashboard)/vector-stores/_components/IndexesTab"; import type { RoutingDecision } from "./logs/detail/RoutingDecisionCard"; +import type { RunListRequest } from "./lens/traces/api"; import type { SpanDetail, SpanErrorPage, Trace, TracePage } from "./lens/traces/types"; import { createApiClient, @@ -1957,20 +1958,18 @@ export const uiSpendLogsCall = async ({ * Agent tracing. All three respond 501 `{detail}` when `general_settings.tracing` is not * configured; callers can detect that through the thrown `ApiError`'s `status`. */ -export const agentTraceListCall = async ({ - accessToken, - startMs, - endMs, - q, - cursor, -}: { - accessToken: string; - startMs: number; - endMs: number; - q: string; - cursor?: string | null; -}): Promise => { - const query = { start_ms: startMs, end_ms: endMs, q: q || undefined, cursor: cursor ?? undefined }; +export const agentTraceListCall = async ( + accessToken: string, + { selection, order, page }: RunListRequest, +): Promise => { + const query = { + start_ms: selection.window.startMs, + end_ms: selection.window.endMs, + q: selection.q || undefined, + sort_by: order.key, + sort_dir: order.descending ? "desc" : "asc", + cursor: page.cursor ?? undefined, + }; return apiClient.get(`/v1/traces`, { accessToken, query }); }; diff --git a/ui/litellm-dashboard/src/components/shared/InspectorTable.tsx b/ui/litellm-dashboard/src/components/shared/InspectorTable.tsx index 059ad1673ee..d6eefb7f325 100644 --- a/ui/litellm-dashboard/src/components/shared/InspectorTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/InspectorTable.tsx @@ -2,7 +2,7 @@ import "@/components/shared/DataTable/columnMeta"; -import { flexRender, type Row as TanStackRow, type Table as TanStackTable } from "@tanstack/react-table"; +import { type Column, flexRender, type Row as TanStackRow, type Table as TanStackTable } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronRight } from "lucide-react"; import { createContext, Fragment, useContext, useState, type ComponentProps, type ReactNode } from "react"; @@ -63,7 +63,21 @@ function Grid({ className, children, ...props }: ComponentProps<"table">) { ); } -function Header({ hidden = false }: { readonly hidden?: boolean }) { +const ARIA_SORT = { asc: "ascending", desc: "descending" } as const; + +function ariaSort(column: Column): "ascending" | "descending" | "none" | undefined { + if (!column.getCanSort()) return undefined; + const sorted = column.getIsSorted(); + return sorted === false ? "none" : ARIA_SORT[sorted]; +} + +interface HeaderProps { + readonly hidden?: boolean; + /** Shows a progress line under the headings while the rows below are being replaced. */ + readonly busy?: boolean; +} + +function Header({ hidden = false, busy = false }: HeaderProps) { const { table } = useInspectorTable(); return (