Merge pull request #32432 from thibault-linktree/litellm_ui_session_sidebar_sort_toggle

feat(ui): sort session sidebar calls by duration or start time
This commit is contained in:
yuneng-jiang 2026-07-08 09:22:41 -07:00 • committed by GitHub
commit cbc6a79972
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 206 additions and 19 deletions

View file

@ -0,0 +1,120 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { LogDetailsDrawer } from "./LogDetailsDrawer";
import { sessionSpendLogsCall } from "../../networking";
import { LogEntry } from "../columns";
vi.mock("../../networking", () => ({
sessionSpendLogsCall: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/logDetails/useLogDetails", () => ({
useLogDetails: () => ({ data: null, isLoading: false }),
}));
vi.mock("./LogDetailContent", () => ({
LogDetailContent: () => null,
GuardrailJumpLink: () => null,
}));
vi.mock("./DrawerHeader", () => ({
DrawerHeader: () => null,
}));
const makeLog = (overrides: Partial<LogEntry>): LogEntry => ({
request_id: "req",
api_key: "",
team_id: "",
model: "",
model_id: "",
call_type: "acompletion",
spend: 0,
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
startTime: "2026-07-08T10:00:00.000Z",
endTime: "2026-07-08T10:00:01.000Z",
cache_hit: "false",
messages: [],
response: {},
...overrides,
});
const sessionLogs = [
makeLog({
request_id: "llm-early",
model: "llm-early",
startTime: "2026-07-08T10:00:00.000Z",
endTime: "2026-07-08T10:00:02.000Z",
}),
makeLog({
request_id: "mcp-early",
model: "tool-early",
call_type: "call_mcp_tool",
startTime: "2026-07-08T10:00:01.000Z",
endTime: "2026-07-08T10:00:06.000Z",
}),
makeLog({
request_id: "llm-late",
model: "llm-late",
startTime: "2026-07-08T10:00:02.000Z",
endTime: "2026-07-08T10:00:05.000Z",
}),
makeLog({
request_id: "mcp-late",
model: "tool-late",
call_type: "call_mcp_tool",
startTime: "2026-07-08T10:00:03.000Z",
endTime: "2026-07-08T10:00:03.500Z",
}),
];
const renderSessionDrawer = () => {
vi.mocked(sessionSpendLogsCall).mockResolvedValue({ data: sessionLogs, total: 4, total_pages: 1 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const drawer = (open: boolean) => (
<QueryClientProvider client={queryClient}>
<LogDetailsDrawer open={open} onClose={() => {}} logEntry={null} sessionId="session-1" accessToken="token" />
</QueryClientProvider>
);
const { rerender } = render(drawer(true));
return { rerender, drawer };
};
const sidebarEventNames = () =>
screen.queryAllByText(/^(llm-early|llm-late|tool-early|tool-late)$/).map((el) => el.textContent);
describe("LogDetailsDrawer session sidebar sorting", () => {
it("defaults to duration order, longest call first across LLM and MCP calls", async () => {
renderSessionDrawer();
await waitFor(() => expect(sidebarEventNames()).toHaveLength(4));
expect(sidebarEventNames()).toEqual(["tool-early", "llm-late", "llm-early", "tool-late"]);
});
it("switches to chronological order across LLM and MCP calls when Start time is selected", async () => {
renderSessionDrawer();
await waitFor(() => expect(sidebarEventNames()).toHaveLength(4));
fireEvent.click(screen.getByText("Start time"));
await waitFor(() => expect(sidebarEventNames()).toEqual(["llm-early", "tool-early", "llm-late", "tool-late"]));
fireEvent.click(screen.getByText("Duration"));
await waitFor(() => expect(sidebarEventNames()).toEqual(["tool-early", "llm-late", "llm-early", "tool-late"]));
});
it("resets the sort mode back to duration when the drawer is closed and reopened", async () => {
const { rerender, drawer } = renderSessionDrawer();
await waitFor(() => expect(sidebarEventNames()).toHaveLength(4));
fireEvent.click(screen.getByText("Start time"));
await waitFor(() => expect(sidebarEventNames()).toEqual(["llm-early", "tool-early", "llm-late", "tool-late"]));
rerender(drawer(false));
rerender(drawer(true));
await waitFor(() => expect(sidebarEventNames()).toEqual(["tool-early", "llm-late", "llm-early", "tool-late"]));
});
});

View file

@ -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, SessionLogSortMode } 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<string | null>(null);
const [sessionSortMode, setSessionSortMode] = useState<SessionLogSortMode>("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 ?? [], sessionSortMode),
[sessionData, sessionSortMode],
);
// 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 is ordered by the selected sort mode, 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<LogEntry | null>(
() =>
sessionLogs.reduce<LogEntry | null>(
@ -222,6 +217,7 @@ export function LogDetailsDrawer({
setIsSidebarCollapsed(false);
} else {
if (isSessionMode) setSelectedSessionRequestId(null);
setSessionSortMode("duration");
setCopiedLeftPanelId(false);
}
}, [open, isSessionMode]);
@ -391,6 +387,19 @@ export function LogDetailsDrawer({
Showing most recent {logsForList.length} of {sessionTotalCount}
</div>
)}
{isSessionMode && (
<Segmented
block
size="small"
className="mt-1.5 [&_.ant-segmented-item-label]:text-[11px]"
options={[
{ label: "Duration", value: "duration" },
{ label: "Start time", value: "start_time" },
]}
value={sessionSortMode}
onChange={(value) => setSessionSortMode(value as SessionLogSortMode)}
/>
)}
</div>
<div className="flex-1 overflow-y-auto">

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { sortSessionLogs } from "./utils";
const log = (id: string, startTime: string, endTime: string, request_duration_ms?: number) => ({
request_id: id,
startTime,
endTime,
request_duration_ms,
});
const ids = (rows: { request_id: string }[]) => rows.map((row) => row.request_id);
describe("sortSessionLogs", () => {
const rows = [
log("mid-duration", "2026-07-08T10:00:01.000Z", "2026-07-08T10:00:01.500Z", 2000),
log("longest", "2026-07-08T10:00:02.000Z", "2026-07-08T10:00:02.500Z", 5000),
log("shortest", "2026-07-08T10:00:03.000Z", "2026-07-08T10:00:03.500Z", 300),
log("earliest-no-duration-field", "2026-07-08T10:00:00.000Z", "2026-07-08T10:00:04.000Z"),
];
it("duration mode sorts longest call first, deriving duration from timestamps when the field is missing", () => {
expect(ids(sortSessionLogs(rows, "duration"))).toEqual([
"longest",
"earliest-no-duration-field",
"mid-duration",
"shortest",
]);
});
it("start_time mode sorts calls in the order they started", () => {
expect(ids(sortSessionLogs(rows, "start_time"))).toEqual([
"earliest-no-duration-field",
"mid-duration",
"longest",
"shortest",
]);
});
it("does not mutate the input array", () => {
const input = [...rows];
sortSessionLogs(input, "duration");
expect(ids(input)).toEqual(ids(rows));
});
});

View file

@ -3,6 +3,20 @@
* These functions handle data formatting, validation, and guardrail calculations.
*/
export type SessionLogSortMode = "duration" | "start_time";
type SortableSessionLog = { startTime: string; endTime: string; request_duration_ms?: number };
const durationMs = (row: SortableSessionLog): number =>
row.request_duration_ms ?? Date.parse(row.endTime) - Date.parse(row.startTime);
export function sortSessionLogs<T extends SortableSessionLog>(rows: T[], mode: SessionLogSortMode): T[] {
if (mode === "start_time") {
return [...rows].sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
}
return [...rows].sort((a, b) => durationMs(b) - durationMs(a));
}
/**
* Formats data for display. If input is a string, attempts to parse as JSON.
* @param input - Data to format (string or object)