mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
feat(ui): add start time sort toggle to session logs sidebar
This commit is contained in:
parent
d6cbf6e7e3
commit
34db5f4813
4 changed files with 179 additions and 16 deletions
|
|
@ -0,0 +1,105 @@
|
|||
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:01.500Z",
|
||||
}),
|
||||
makeLog({
|
||||
request_id: "llm-late",
|
||||
model: "llm-late",
|
||||
startTime: "2026-07-08T10:00:02.000Z",
|
||||
endTime: "2026-07-08T10:00:04.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 } } });
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LogDetailsDrawer open onClose={() => {}} logEntry={null} sessionId="session-1" accessToken="token" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const sidebarEventNames = () =>
|
||||
screen.queryAllByText(/^(llm-early|llm-late|tool-early|tool-late)$/).map((el) => el.textContent);
|
||||
|
||||
describe("LogDetailsDrawer session sidebar sorting", () => {
|
||||
it("defaults to grouped order: LLM calls newest first, MCP calls grouped last", async () => {
|
||||
renderSessionDrawer();
|
||||
await waitFor(() => expect(sidebarEventNames()).toHaveLength(4));
|
||||
expect(sidebarEventNames()).toEqual(["llm-late", "llm-early", "tool-late", "tool-early"]);
|
||||
});
|
||||
|
||||
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("Grouped"));
|
||||
|
||||
await waitFor(() => expect(sidebarEventNames()).toEqual(["llm-late", "llm-early", "tool-late", "tool-early"]));
|
||||
});
|
||||
});
|
||||
|
|
@ -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>("grouped");
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [copiedLeftPanelId, setCopiedLeftPanelId] = useState(false);
|
||||
|
||||
|
|
@ -152,26 +153,20 @@ 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;
|
||||
|
|
@ -391,6 +386,18 @@ export function LogDetailsDrawer({
|
|||
Showing most recent {logsForList.length} of {sessionTotalCount}
|
||||
</div>
|
||||
)}
|
||||
{isSessionMode && (
|
||||
<Segmented
|
||||
size="small"
|
||||
className="mt-1.5"
|
||||
options={[
|
||||
{ label: "Grouped", value: "grouped" },
|
||||
{ label: "Start time", value: "chronological" },
|
||||
]}
|
||||
value={sessionSortMode}
|
||||
onChange={(value) => setSessionSortMode(value as SessionLogSortMode)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { sortSessionLogs } from "./utils";
|
||||
|
||||
const llm = (id: string, startTime: string) => ({ request_id: id, call_type: "acompletion", startTime });
|
||||
const mcp = (id: string, startTime: string) => ({ request_id: id, call_type: "call_mcp_tool", startTime });
|
||||
|
||||
const ids = (rows: { request_id: string }[]) => rows.map((row) => row.request_id);
|
||||
|
||||
describe("sortSessionLogs", () => {
|
||||
const rows = [
|
||||
mcp("mcp-early", "2026-07-08T10:00:01.000Z"),
|
||||
llm("llm-late", "2026-07-08T10:00:02.000Z"),
|
||||
mcp("mcp-late", "2026-07-08T10:00:03.000Z"),
|
||||
llm("llm-early", "2026-07-08T10:00:00.000Z"),
|
||||
];
|
||||
|
||||
it("grouped mode keeps MCP calls last, newest first within each group", () => {
|
||||
expect(ids(sortSessionLogs(rows, "grouped"))).toEqual(["llm-late", "llm-early", "mcp-late", "mcp-early"]);
|
||||
});
|
||||
|
||||
it("chronological mode interleaves all calls by start time, oldest first", () => {
|
||||
expect(ids(sortSessionLogs(rows, "chronological"))).toEqual(["llm-early", "mcp-early", "llm-late", "mcp-late"]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const input = [...rows];
|
||||
sortSessionLogs(input, "chronological");
|
||||
expect(ids(input)).toEqual(ids(rows));
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,27 @@
|
|||
* These functions handle data formatting, validation, and guardrail calculations.
|
||||
*/
|
||||
|
||||
import { MCP_CALL_TYPES } from "../constants";
|
||||
|
||||
export type SessionLogSortMode = "grouped" | "chronological";
|
||||
|
||||
export function sortSessionLogs<T extends { call_type: string; startTime: string }>(
|
||||
rows: T[],
|
||||
mode: SessionLogSortMode,
|
||||
): T[] {
|
||||
if (mode === "chronological") {
|
||||
return [...rows].sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
|
||||
}
|
||||
return [...rows].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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats data for display. If input is a string, attempts to parse as JSON.
|
||||
* @param input - Data to format (string or object)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue