From 62992a9389102127d57bdfa6ac495b9782711661 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:07:38 +0000 Subject: [PATCH] feat(ui): add sort toggle (duration/start time) to session logs sidebar --- .../LogDetailsDrawer/LogDetailsDrawer.tsx | 48 +++++++----- .../LogDetailsDrawer/sortSessionLogs.test.ts | 78 +++++++++++++++++++ .../view_logs/LogDetailsDrawer/utils.ts | 19 +++++ 3 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/sortSessionLogs.test.ts diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx index bf3360a5371..345969fd2bb 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Button, Drawer } from "antd"; +import { Button, Drawer, Segmented } from "antd"; import { CheckOutlined, CopyOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons"; import { Bot, Sparkles, Wrench } from "lucide-react"; import { LogEntry } from "../columns"; @@ -11,7 +11,7 @@ import { LogDetailContent, GuardrailJumpLink } from "./LogDetailContent"; import { sessionSpendLogsCall } from "../../networking"; import { useQuery } from "@tanstack/react-query"; import { getSpendString } from "@/utils/dataUtils"; -import { normalizeGuardrailEntries } from "./utils"; +import { normalizeGuardrailEntries, sortSessionLogs, SessionSortBy } from "./utils"; import { DRAWER_WIDTH } from "./constants"; import { useLogDetails } from "@/app/(dashboard)/hooks/logDetails/useLogDetails"; @@ -117,6 +117,7 @@ export function LogDetailsDrawer({ }: LogDetailsDrawerProps) { const isSessionMode = Boolean(sessionId); const [selectedSessionRequestId, setSelectedSessionRequestId] = useState(null); + const [sessionSortBy, setSessionSortBy] = useState("duration"); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [copiedLeftPanelId, setCopiedLeftPanelId] = useState(false); @@ -152,35 +153,29 @@ export function LogDetailsDrawer({ // backend omits total, so the truncation note reflects what was fetched. const total: number = firstPage.total ?? rows.length; - const logs = rows - .map((row) => ({ - ...row, - request_duration_ms: row.request_duration_ms ?? Date.parse(row.endTime) - Date.parse(row.startTime), - })) - .sort((a, b) => { - const aIsMcp = MCP_CALL_TYPES.includes(a.call_type) ? 1 : 0; - const bIsMcp = MCP_CALL_TYPES.includes(b.call_type) ? 1 : 0; - if (aIsMcp !== bIsMcp) return aIsMcp - bIsMcp; - // Newest first, matching the all-sessions logs overview. MCP calls - // stay grouped last (above), newest-first within that group too. - return new Date(b.startTime).getTime() - new Date(a.startTime).getTime(); - }); + const logs = rows.map((row) => ({ + ...row, + request_duration_ms: row.request_duration_ms ?? Date.parse(row.endTime) - Date.parse(row.startTime), + })); return { logs, total }; }, enabled: Boolean(open && isSessionMode && sessionId && accessToken), }); - const sessionLogs: LogEntry[] = sessionData?.logs ?? []; + const sessionLogs: LogEntry[] = useMemo( + () => sortSessionLogs(sessionData?.logs ?? [], sessionSortBy), + [sessionData?.logs, sessionSortBy], + ); // total reported by the backend; when the page cap truncates the fetch this // exceeds sessionLogs.length, which drives the "showing most recent" note. const sessionTotalCount = sessionData?.total ?? sessionLogs.length; const sessionTruncated = sessionTotalCount > sessionLogs.length; // Default selection for a freshly opened session: the most recent log (latest - // startTime). The list is sorted newest-first, but MCP calls are grouped last, - // so the latest log by time is not necessarily sessionLogs[0]; compute it - // explicitly. A clicked/remembered log still wins over this default. + // startTime). The list order depends on the chosen sort, so the latest log by + // time is not necessarily sessionLogs[0]; compute it explicitly. A + // clicked/remembered log still wins over this default. const mostRecentLog = useMemo( () => sessionLogs.reduce( @@ -393,6 +388,21 @@ export function LogDetailsDrawer({ )} + {isSessionMode && ( +
+ Sort by + setSessionSortBy(value as SessionSortBy)} + options={[ + { label: "Duration", value: "duration" }, + { label: "Start time", value: "start_time" }, + ]} + /> +
+ )} +
{normalizeGuardrailEntries(metadata?.guardrail_information).length > 0 && (
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/sortSessionLogs.test.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/sortSessionLogs.test.ts new file mode 100644 index 00000000000..23ab6b2d8df --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/sortSessionLogs.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { sortSessionLogs, getRowDurationMs } from "./utils"; +import { LogEntry } from "../columns"; + +function makeLog(overrides: Partial): LogEntry { + return { + request_id: "req", + startTime: "2026-07-08T00:00:00.000Z", + endTime: "2026-07-08T00:00:01.000Z", + call_type: "completion", + ...overrides, + } as LogEntry; +} + +describe("getRowDurationMs", () => { + it("prefers request_duration_ms when present", () => { + const row = makeLog({ + request_duration_ms: 4200, + startTime: "2026-07-08T00:00:00.000Z", + endTime: "2026-07-08T00:00:01.000Z", + }); + expect(getRowDurationMs(row)).toBe(4200); + }); + + it("falls back to endTime minus startTime when duration missing", () => { + const row = makeLog({ + request_duration_ms: undefined, + startTime: "2026-07-08T00:00:00.000Z", + endTime: "2026-07-08T00:00:02.500Z", + }); + expect(getRowDurationMs(row)).toBe(2500); + }); + + it("returns 0 when timestamps are unparseable and no duration", () => { + const row = makeLog({ request_duration_ms: undefined, startTime: "nope", endTime: "nope" }); + expect(getRowDurationMs(row)).toBe(0); + }); +}); + +describe("sortSessionLogs", () => { + const llmEarlyShort = makeLog({ + request_id: "llm-early-short", + call_type: "completion", + startTime: "2026-07-08T00:00:00.000Z", + request_duration_ms: 500, + }); + const mcpMiddleLong = makeLog({ + request_id: "mcp-middle-long", + call_type: "call_mcp_tool", + startTime: "2026-07-08T00:00:05.000Z", + request_duration_ms: 9000, + }); + const llmLateMedium = makeLog({ + request_id: "llm-late-medium", + call_type: "completion", + startTime: "2026-07-08T00:00:10.000Z", + request_duration_ms: 3000, + }); + + const logs = [llmLateMedium, llmEarlyShort, mcpMiddleLong]; + + it("sorts by duration descending (longest first), interleaving MCP and LLM", () => { + const result = sortSessionLogs(logs, "duration").map((r) => r.request_id); + expect(result).toEqual(["mcp-middle-long", "llm-late-medium", "llm-early-short"]); + }); + + it("sorts by start time ascending (chronological), interleaving MCP and LLM", () => { + const result = sortSessionLogs(logs, "start_time").map((r) => r.request_id); + expect(result).toEqual(["llm-early-short", "mcp-middle-long", "llm-late-medium"]); + }); + + it("does not mutate the input array", () => { + const input = [llmLateMedium, llmEarlyShort, mcpMiddleLong]; + const snapshot = [...input]; + sortSessionLogs(input, "duration"); + expect(input).toEqual(snapshot); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/utils.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/utils.ts index 61301cf5b54..6c10e2ddcb1 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/utils.ts +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/utils.ts @@ -3,6 +3,25 @@ * These functions handle data formatting, validation, and guardrail calculations. */ +import { LogEntry } from "../columns"; + +export type SessionSortBy = "duration" | "start_time"; + +export function getRowDurationMs(row: LogEntry): number { + if (row.request_duration_ms != null) return row.request_duration_ms; + const start = Date.parse(row.startTime); + const end = Date.parse(row.endTime); + if (Number.isNaN(start) || Number.isNaN(end)) return 0; + return end - start; +} + +export function sortSessionLogs(logs: readonly LogEntry[], sortBy: SessionSortBy): LogEntry[] { + if (sortBy === "start_time") { + return [...logs].sort((a, b) => Date.parse(a.startTime) - Date.parse(b.startTime)); + } + return [...logs].sort((a, b) => getRowDurationMs(b) - getRowDurationMs(a)); +} + /** * Formats data for display. If input is a string, attempts to parse as JSON. * @param input - Data to format (string or object)