mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
feat(ui): add sort toggle (duration/start time) to session logs sidebar
This commit is contained in:
parent
6f6bd45681
commit
62992a9389
3 changed files with 126 additions and 19 deletions
|
|
@ -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<string | null>(null);
|
||||
const [sessionSortBy, setSessionSortBy] = useState<SessionSortBy>("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<LogEntry | null>(
|
||||
() =>
|
||||
sessionLogs.reduce<LogEntry | null>(
|
||||
|
|
@ -393,6 +388,21 @@ export function LogDetailsDrawer({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{isSessionMode && (
|
||||
<div className="px-3 py-1.5 border-b border-slate-200 bg-white flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wide text-slate-500 shrink-0">Sort by</span>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={sessionSortBy}
|
||||
onChange={(value) => setSessionSortBy(value as SessionSortBy)}
|
||||
options={[
|
||||
{ label: "Duration", value: "duration" },
|
||||
{ label: "Start time", value: "start_time" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{normalizeGuardrailEntries(metadata?.guardrail_information).length > 0 && (
|
||||
<div className="px-3 pt-2">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { sortSessionLogs, getRowDurationMs } from "./utils";
|
||||
import { LogEntry } from "../columns";
|
||||
|
||||
function makeLog(overrides: Partial<LogEntry>): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue