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 (