diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py
index 9aae9ca2875..42788227acc 100644
--- a/litellm/proxy/spend_tracking/spend_management_endpoints.py
+++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py
@@ -3308,8 +3308,36 @@ async def ui_view_session_spend_logs(
detail="Database not connected",
)
- # Build query conditions
- where_conditions = {"session_id": session_id}
+ if _is_admin_view_safe(user_api_key_dict=user_api_key_dict):
+ scope_sql = ""
+ scope_params = ()
+ where_conditions = {"session_id": session_id}
+ else:
+ try:
+ permitted_team_ids = (
+ await _get_permitted_team_ids_for_spend_logs(
+ prisma_client=prisma_client,
+ user_api_key_dict=user_api_key_dict,
+ )
+ if _can_user_view_spend_log(user_api_key_dict=user_api_key_dict)
+ else []
+ )
+ except Exception: # noqa: BLE001 # mirror /spend/logs/ui: failed team lookup falls back to own-logs-only scope
+ permitted_team_ids = []
+ if permitted_team_ids:
+ scope_sql = ' AND ("user" = $4 OR team_id = ANY($5::text[]))'
+ scope_params = (user_api_key_dict.user_id, permitted_team_ids)
+ where_conditions = {
+ "session_id": session_id,
+ "OR": [
+ {"user": user_api_key_dict.user_id},
+ {"team_id": {"in": permitted_team_ids}},
+ ],
+ }
+ else:
+ scope_sql = ' AND "user" = $4'
+ scope_params = (user_api_key_dict.user_id,)
+ where_conditions = {"session_id": session_id, "user": user_api_key_dict.user_id}
# Calculate pagination offsets
skip = (page - 1) * page_size
@@ -3318,7 +3346,7 @@ async def ui_view_session_spend_logs(
total_records = await SpendLogsRepository(prisma_client).table.count(where=where_conditions)
# Query with raw SQL to exclude heavy columns (messages, response, proxy_server_request)
- sql_query = """
+ sql_query = f"""
SELECT
request_id, call_type, api_key, spend, total_tokens,
prompt_tokens, completion_tokens, "startTime", "endTime",
@@ -3328,11 +3356,11 @@ async def ui_view_session_spend_logs(
organization_id, end_user, requester_ip_address,
session_id, status, mcp_namespaced_tool_name, agent_id
FROM "LiteLLM_SpendLogs"
- WHERE session_id = $1
+ WHERE session_id = $1{scope_sql}
ORDER BY "startTime" DESC
LIMIT $2 OFFSET $3
"""
- result = await prisma_client.db.query_raw(sql_query, session_id, page_size, skip)
+ result = await prisma_client.db.query_raw(sql_query, session_id, page_size, skip, *scope_params)
total_pages = (total_records + page_size - 1) // page_size
diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
index 67945436987..71206687b5c 100644
--- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
+++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
@@ -1548,6 +1548,7 @@ async def test_ui_view_session_spend_logs_pagination(client, monkeypatch):
assert page_size == 1
assert skip == 1 # page=2, page_size=1
assert 'ORDER BY "startTime" DESC' in sql_query
+ assert '"user" = $4' not in sql_query
return [mock_spend_logs[0]]
class MockPrismaClient:
@@ -1558,20 +1559,144 @@ async def test_ui_view_session_spend_logs_pagination(client, monkeypatch):
mock_prisma_client = MockPrismaClient()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
- response = client.get(
- "/spend/logs/session/ui",
- params={"session_id": "session-123", "page": 2, "page_size": 1},
- headers={"Authorization": "Bearer sk-test"},
+ app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
+ user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
- assert response.status_code == 200
- data = response.json()
- assert data["total"] == 2
- assert data["page"] == 2
- assert data["page_size"] == 1
- assert data["total_pages"] == 2
- assert len(data["data"]) == 1
- assert data["data"][0]["request_id"] == "req1"
+ try:
+ response = client.get(
+ "/spend/logs/session/ui",
+ params={"session_id": "session-123", "page": 2, "page_size": 1},
+ headers={"Authorization": "Bearer sk-test"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["total"] == 2
+ assert data["page"] == 2
+ assert data["page_size"] == 1
+ assert data["total_pages"] == 2
+ assert len(data["data"]) == 1
+ assert data["data"][0]["request_id"] == "req1"
+ finally:
+ app.dependency_overrides.pop(ps.user_api_key_auth, None)
+
+
+@pytest.mark.asyncio
+async def test_ui_view_session_spend_logs_scopes_non_admin_to_own_logs(client, monkeypatch):
+ own_log = {
+ "id": "log1",
+ "request_id": "req1",
+ "session_id": "session-123",
+ "user": "user-1",
+ "startTime": "2024-01-01T00:00:00Z",
+ }
+
+ class MockDB:
+ async def count(self, *args, **kwargs):
+ assert kwargs.get("where") == {"session_id": "session-123", "user": "user-1"}
+ return 1
+
+ async def query_raw(self, sql_query, session_id, page_size, skip, scoped_user):
+ assert session_id == "session-123"
+ assert scoped_user == "user-1"
+ assert '"user" = $4' in sql_query
+ return [own_log]
+
+ class MockPrismaClient:
+ def __init__(self):
+ self.db = MockDB()
+ self.db.litellm_spendlogs = self.db
+
+ monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MockPrismaClient())
+
+ async def no_permitted_teams(*args, **kwargs):
+ return []
+
+ monkeypatch.setattr(
+ "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
+ no_permitted_teams,
+ )
+
+ app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
+ user_role=LitellmUserRoles.INTERNAL_USER, user_id="user-1"
+ )
+
+ try:
+ response = client.get(
+ "/spend/logs/session/ui",
+ params={"session_id": "session-123", "page": 1, "page_size": 50},
+ headers={"Authorization": "Bearer sk-test"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["total"] == 1
+ assert [row["request_id"] for row in data["data"]] == ["req1"]
+ finally:
+ app.dependency_overrides.pop(ps.user_api_key_auth, None)
+
+
+@pytest.mark.asyncio
+async def test_ui_view_session_spend_logs_includes_permitted_team_logs(client, monkeypatch):
+ class MockDB:
+ async def count(self, *args, **kwargs):
+ assert kwargs.get("where") == {
+ "session_id": "session-123",
+ "OR": [
+ {"user": "user-1"},
+ {"team_id": {"in": ["team-9"]}},
+ ],
+ }
+ return 1
+
+ async def query_raw(self, sql_query, session_id, page_size, skip, scoped_user, team_ids):
+ assert session_id == "session-123"
+ assert scoped_user == "user-1"
+ assert team_ids == ["team-9"]
+ assert '("user" = $4 OR team_id = ANY($5::text[]))' in sql_query
+ return [
+ {
+ "id": "log2",
+ "request_id": "req2",
+ "session_id": "session-123",
+ "team_id": "team-9",
+ "startTime": "2024-01-02T00:00:00Z",
+ }
+ ]
+
+ class MockPrismaClient:
+ def __init__(self):
+ self.db = MockDB()
+ self.db.litellm_spendlogs = self.db
+
+ monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MockPrismaClient())
+
+ async def permitted_teams(*args, **kwargs):
+ return ["team-9"]
+
+ monkeypatch.setattr(
+ "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
+ permitted_teams,
+ )
+
+ app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
+ user_role=LitellmUserRoles.INTERNAL_USER, user_id="user-1"
+ )
+
+ try:
+ response = client.get(
+ "/spend/logs/session/ui",
+ params={"session_id": "session-123", "page": 1, "page_size": 50},
+ headers={"Authorization": "Bearer sk-test"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["total"] == 1
+ assert [row["request_id"] for row in data["data"]] == ["req2"]
+ finally:
+ app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/navigateWithParams.ts b/ui/litellm-dashboard/src/app/(dashboard)/navigateWithParams.ts
index c49c73c3578..5acf444a359 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/navigateWithParams.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/navigateWithParams.ts
@@ -1,7 +1,11 @@
-export function navigateWithParams(mutate: (params: URLSearchParams) => void): void {
+export function navigateWithParams(mutate: (params: URLSearchParams) => void, mode: "push" | "replace" = "push"): void {
const params = new URLSearchParams(window.location.search);
mutate(params);
const qs = params.toString();
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
- window.history.pushState(null, "", url);
+ if (mode === "replace") {
+ window.history.replaceState(null, "", url);
+ } else {
+ window.history.pushState(null, "", url);
+ }
}
diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx
index e8e2fae3f4d..49870e51c2b 100644
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx
@@ -1,7 +1,7 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import moment from "moment";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
import type { LogEntry } from "./columns";
@@ -22,11 +22,75 @@ vi.mock("@/components/key_team_helpers/filter_helpers", () => ({
}));
vi.mock("./LogDetailsDrawer", () => ({
- LogDetailsDrawer: function LogDetailsDrawerMock({ open }: { open: boolean }) {
- return
{open ? "open" : "closed"}
;
+ LogDetailsDrawer: function LogDetailsDrawerMock({
+ open,
+ logEntry,
+ sessionId,
+ onClose,
+ allLogs = [],
+ onSelectLog,
+ }: {
+ open: boolean;
+ logEntry?: { request_id: string } | null;
+ sessionId?: string | null;
+ onClose: () => void;
+ allLogs?: { request_id: string }[];
+ onSelectLog?: (log: { request_id: string }) => void;
+ }) {
+ const nextLog = allLogs.find((log) => log.request_id !== logEntry?.request_id);
+ return (
+
+ {open ? "open" : "closed"}
+
+
+
+ );
},
}));
+vi.mock("next/navigation", async (importOriginal) => {
+ const actual = await importOriginal();
+ const { useSyncExternalStore } = await import("react");
+ return {
+ ...actual,
+ useSearchParams: () => {
+ const search = useSyncExternalStore(
+ (onChange: () => void) => {
+ window.addEventListener("test-locationchange", onChange);
+ window.addEventListener("popstate", onChange);
+ return () => {
+ window.removeEventListener("test-locationchange", onChange);
+ window.removeEventListener("popstate", onChange);
+ };
+ },
+ () => window.location.search,
+ );
+ return new URLSearchParams(search);
+ },
+ };
+});
+
+const originalPushState = window.history.pushState.bind(window.history);
+const originalReplaceState = window.history.replaceState.bind(window.history);
+beforeAll(() => {
+ window.history.pushState = (data, unused, url) => {
+ originalPushState(data, unused, url);
+ window.dispatchEvent(new Event("test-locationchange"));
+ };
+ window.history.replaceState = (data, unused, url) => {
+ originalReplaceState(data, unused, url);
+ window.dispatchEvent(new Event("test-locationchange"));
+ };
+});
+afterAll(() => {
+ window.history.pushState = originalPushState;
+ window.history.replaceState = originalReplaceState;
+});
+
import { uiSpendLogsCall } from "../networking";
const logEntry = (overrides: Partial): LogEntry => ({
@@ -73,6 +137,7 @@ describe("RequestLogsPanel", () => {
vi.clearAllMocks();
sessionStorage.clear();
testQueryClient.clear();
+ window.history.replaceState(null, "", "/logs/");
respondWith([]);
});
@@ -185,6 +250,161 @@ describe("RequestLogsPanel", () => {
});
});
+ describe("shareable log links (?log_id=)", () => {
+ const drawer = () => screen.getByTestId("log-details-drawer");
+
+ it("clicking a row writes ?log_id= to the URL and opens the drawer", async () => {
+ const user = userEvent.setup();
+ respondWith([logEntry({ request_id: "req-1" })]);
+ renderWithProviders();
+
+ await waitFor(() => expect(row("req-1")).not.toBeNull());
+ await user.click(row("req-1") as HTMLElement);
+
+ expect(new URLSearchParams(window.location.search).get("log_id")).toBe("req-1");
+ await waitFor(() => {
+ expect(drawer()).toHaveTextContent("open");
+ expect(drawer()).toHaveAttribute("data-log-id", "req-1");
+ });
+ });
+
+ it("opens the drawer on load when ?log_id= matches a log in the loaded page", async () => {
+ window.history.replaceState(null, "", "/logs/?log_id=req-2");
+ respondWith([logEntry({ request_id: "req-1" }), logEntry({ request_id: "req-2" })]);
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(drawer()).toHaveTextContent("open");
+ expect(drawer()).toHaveAttribute("data-log-id", "req-2");
+ });
+ });
+
+ it("fetches the log by request_id and opens the drawer when it is not in the loaded page", async () => {
+ window.history.replaceState(null, "", "/logs/?log_id=req-old");
+ vi.mocked(uiSpendLogsCall).mockImplementation(async ({ params }) =>
+ params?.request_id === "req-old"
+ ? { data: [logEntry({ request_id: "req-old" })], total: 1, page: 1, page_size: 1, total_pages: 1 }
+ : { data: [], total: 0, page: 1, page_size: 50, total_pages: 0 },
+ );
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(drawer()).toHaveTextContent("open");
+ expect(drawer()).toHaveAttribute("data-log-id", "req-old");
+ });
+
+ const byIdCall = vi
+ .mocked(uiSpendLogsCall)
+ .mock.calls.find(([options]) => options.params?.request_id === "req-old")?.[0];
+ if (!byIdCall) throw new Error("expected a by-id uiSpendLogsCall");
+ expect(byIdCall.page).toBe(1);
+ expect(byIdCall.page_size).toBe(1);
+ });
+
+ it("closing the drawer removes ?log_id= from the URL and closes the drawer", async () => {
+ const user = userEvent.setup();
+ respondWith([logEntry({ request_id: "req-1" })]);
+ renderWithProviders();
+
+ await waitFor(() => expect(row("req-1")).not.toBeNull());
+ await user.click(row("req-1") as HTMLElement);
+ await waitFor(() => expect(drawer()).toHaveTextContent("open"));
+
+ await user.click(screen.getByRole("button", { name: "close-drawer" }));
+
+ expect(new URLSearchParams(window.location.search).get("log_id")).toBeNull();
+ await waitFor(() => expect(drawer()).toHaveTextContent("closed"));
+ });
+
+ it("switching logs inside the drawer replaces the URL, so back closes the drawer in one step", async () => {
+ const user = userEvent.setup();
+ respondWith([logEntry({ request_id: "req-1" }), logEntry({ request_id: "req-2" })]);
+ renderWithProviders();
+
+ await waitFor(() => expect(row("req-1")).not.toBeNull());
+ await user.click(row("req-1") as HTMLElement);
+ await waitFor(() => expect(drawer()).toHaveAttribute("data-log-id", "req-1"));
+
+ await user.click(screen.getByRole("button", { name: "select-next-log" }));
+ await waitFor(() => expect(drawer()).toHaveAttribute("data-log-id", "req-2"));
+ expect(new URLSearchParams(window.location.search).get("log_id")).toBe("req-2");
+
+ window.history.back();
+
+ await waitFor(() => expect(drawer()).toHaveTextContent("closed"));
+ expect(new URLSearchParams(window.location.search).get("log_id")).toBeNull();
+ });
+
+ it("clicking a session id writes ?session_id= and ?log_id= and opens the session drawer", async () => {
+ const user = userEvent.setup();
+ respondWith([logEntry({ request_id: "req-solo", session_id: "sess-solo", session_total_count: 1 })]);
+ renderWithProviders();
+
+ await waitFor(() => expect(row("req-solo")).not.toBeNull());
+ await user.click(within(row("req-solo") as HTMLElement).getByText("sess-solo"));
+
+ const params = new URLSearchParams(window.location.search);
+ expect(params.get("session_id")).toBe("sess-solo");
+ expect(params.get("log_id")).toBe("req-solo");
+ await waitFor(() => {
+ expect(drawer()).toHaveTextContent("open");
+ expect(drawer()).toHaveAttribute("data-session-id", "sess-solo");
+ });
+ });
+
+ it("clicking a log row clears a lingering ?session_id= so the drawer shows the clicked log", async () => {
+ const user = userEvent.setup();
+ respondWith([
+ logEntry({ request_id: "req-a", session_id: "sess-a", session_total_count: 1 }),
+ logEntry({ request_id: "req-b" }),
+ ]);
+ renderWithProviders();
+
+ await waitFor(() => expect(row("req-a")).not.toBeNull());
+ await user.click(within(row("req-a") as HTMLElement).getByText("sess-a"));
+ await waitFor(() => expect(new URLSearchParams(window.location.search).get("session_id")).toBe("sess-a"));
+
+ await user.click(row("req-b") as HTMLElement);
+
+ const params = new URLSearchParams(window.location.search);
+ expect(params.get("log_id")).toBe("req-b");
+ expect(params.get("session_id")).toBeNull();
+ await waitFor(() => {
+ expect(drawer()).toHaveAttribute("data-log-id", "req-b");
+ expect(drawer()).toHaveAttribute("data-session-id", "");
+ });
+ });
+
+ it("browser back after opening via a session id closes the drawer", async () => {
+ const user = userEvent.setup();
+ respondWith([logEntry({ request_id: "req-solo", session_id: "sess-solo", session_total_count: 1 })]);
+ renderWithProviders();
+
+ await waitFor(() => expect(row("req-solo")).not.toBeNull());
+ await user.click(within(row("req-solo") as HTMLElement).getByText("sess-solo"));
+ await waitFor(() => expect(drawer()).toHaveTextContent("open"));
+
+ window.history.back();
+
+ await waitFor(() => expect(drawer()).toHaveTextContent("closed"));
+ expect(new URLSearchParams(window.location.search).get("session_id")).toBeNull();
+ });
+
+ it("opens a deep-linked multi-call session log in session mode", async () => {
+ window.history.replaceState(null, "", "/logs/?log_id=req-llm");
+ respondWith([
+ logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
+ ]);
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(drawer()).toHaveTextContent("open");
+ expect(drawer()).toHaveAttribute("data-log-id", "req-llm");
+ expect(drawer()).toHaveAttribute("data-session-id", "sess-1");
+ });
+ });
+ });
+
describe("live tail", () => {
it("shows the auto-refresh banner on the first page and hides it once stopped", async () => {
const user = userEvent.setup();
diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx
index b3b1e8c0640..06c8ca26a7e 100644
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx
@@ -8,7 +8,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells";
import { internalUserRoles } from "../../utils/roles";
import type { KeyResponse } from "../key_team_helpers/key_list";
-import { keyInfoV1Call } from "../networking";
+import { keyInfoV1Call, uiSpendLogsCall } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import type { LogEntry } from "./columns";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
@@ -17,8 +17,10 @@ import {
formatLogsWindow,
getLogsWindowEndBound,
LOG_FILTER_IDS,
+ type PaginatedResponse,
useLogFilterLogic,
} from "./log_filter_logic";
+import { useLogDetailRouting } from "./logDetailRouting";
import { LogDetailsDrawer } from "./LogDetailsDrawer";
import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar";
import { RequestLogsTable } from "./RequestLogsTable";
@@ -52,8 +54,15 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState(null);
const [selectedLog, setSelectedLog] = useState(null);
- const [isDrawerOpen, setIsDrawerOpen] = useState(false);
- const [selectedSessionId, setSelectedSessionId] = useState(null);
+
+ const {
+ logId: urlLogId,
+ sessionId: urlSessionId,
+ openLog,
+ openSession,
+ selectLog,
+ close: closeUrlLog,
+ } = useLogDetailRouting();
const [isLiveTail, setIsLiveTail] = useState(() => {
const storedValue = sessionStorage.getItem("isLiveTail");
@@ -106,6 +115,43 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
const { data: selectedKeyInfo } = useQuery(keyInfoQueryOptions);
+ const urlLogQueryOptions: UseQueryOptions = {
+ queryKey: ["logs", "byId", urlLogId, accessToken],
+ queryFn: async () => {
+ if (urlLogId === null) return null;
+ const window = formatLogsWindow(startTime, endTime, isCustomDate);
+ const response: PaginatedResponse = await uiSpendLogsCall({
+ accessToken,
+ start_date: window.start_date,
+ end_date: window.end_date,
+ page: 1,
+ page_size: 1,
+ params: { request_id: urlLogId },
+ });
+ return response.data.find((log) => log.request_id === urlLogId) ?? null;
+ },
+ enabled: urlLogId !== null && selectedLog?.request_id !== urlLogId,
+ staleTime: Infinity,
+ };
+
+ const { data: urlLog } = useQuery(urlLogQueryOptions);
+
+ const displayLog = useMemo(() => {
+ if (urlLogId === null) return null;
+ if (selectedLog?.request_id === urlLogId) return selectedLog;
+ return filteredLogs.data.find((log) => log.request_id === urlLogId) ?? urlLog ?? null;
+ }, [urlLogId, selectedLog, filteredLogs.data, urlLog]);
+
+ const displaySessionId = useMemo(() => {
+ if (urlSessionId !== null) return urlSessionId;
+ if (displayLog?.session_id !== undefined && (displayLog.session_total_count || 1) > 1) {
+ return displayLog.session_id;
+ }
+ return null;
+ }, [urlSessionId, displayLog]);
+
+ const isDrawerOpen = displayLog !== null || displaySessionId !== null;
+
const rows = useMemo(() => {
const searchedLogs = filteredLogs.data;
@@ -186,22 +232,30 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
resetToFirstPage();
}, [resetToFirstPage]);
- const handleRowClick = useCallback((log: LogEntry) => {
- const isMultiCallSession = log.session_id !== undefined && (log.session_total_count || 1) > 1;
- setSelectedSessionId(isMultiCallSession ? log.session_id ?? null : null);
- setSelectedLog(log);
- setIsDrawerOpen(true);
- }, []);
+ const handleRowClick = useCallback(
+ (log: LogEntry) => {
+ setSelectedLog(log);
+ openLog(log.request_id);
+ },
+ [openLog],
+ );
const handleSessionClick = useCallback(
(sessionId: string) => {
if (!sessionId) return;
const log = rows.find((candidate) => candidate.session_id === sessionId) ?? null;
- setSelectedSessionId(sessionId);
setSelectedLog(log);
- setIsDrawerOpen(true);
+ openSession(sessionId, log?.request_id ?? null);
},
- [rows],
+ [rows, openSession],
+ );
+
+ const handleSelectLog = useCallback(
+ (log: LogEntry) => {
+ setSelectedLog(log);
+ selectLog(log.request_id);
+ },
+ [selectLog],
);
const handleKeyHashClick = useCallback((keyHash: string) => {
@@ -267,15 +321,12 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
{
- setIsDrawerOpen(false);
- setSelectedSessionId(null);
- }}
- logEntry={selectedLog}
- sessionId={selectedSessionId}
+ onClose={closeUrlLog}
+ logEntry={displayLog}
+ sessionId={displaySessionId}
accessToken={accessToken}
allLogs={rows}
- onSelectLog={setSelectedLog}
+ onSelectLog={handleSelectLog}
startTime={moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")}
/>
diff --git a/ui/litellm-dashboard/src/components/view_logs/logDetailRouting.ts b/ui/litellm-dashboard/src/components/view_logs/logDetailRouting.ts
new file mode 100644
index 00000000000..5b311c94627
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/logDetailRouting.ts
@@ -0,0 +1,60 @@
+import { useSearchParams } from "next/navigation";
+import { useCallback } from "react";
+
+import { navigateWithParams } from "@/app/(dashboard)/navigateWithParams";
+
+export const LOG_ID_QUERY_PARAM = "log_id";
+export const SESSION_ID_QUERY_PARAM = "session_id";
+
+export interface LogDetailRouting {
+ logId: string | null;
+ sessionId: string | null;
+ openLog: (requestId: string) => void;
+ openSession: (sessionId: string, requestId: string | null) => void;
+ selectLog: (requestId: string) => void;
+ close: () => void;
+}
+
+export function useLogDetailRouting(): LogDetailRouting {
+ const searchParams = useSearchParams();
+
+ const openLog = useCallback((requestId: string) => {
+ navigateWithParams((params) => {
+ params.set(LOG_ID_QUERY_PARAM, requestId);
+ params.delete(SESSION_ID_QUERY_PARAM);
+ });
+ }, []);
+
+ const openSession = useCallback((sessionId: string, requestId: string | null) => {
+ navigateWithParams((params) => {
+ params.set(SESSION_ID_QUERY_PARAM, sessionId);
+ if (requestId === null) {
+ params.delete(LOG_ID_QUERY_PARAM);
+ } else {
+ params.set(LOG_ID_QUERY_PARAM, requestId);
+ }
+ });
+ }, []);
+
+ const selectLog = useCallback((requestId: string) => {
+ navigateWithParams((params) => {
+ params.set(LOG_ID_QUERY_PARAM, requestId);
+ }, "replace");
+ }, []);
+
+ const close = useCallback(() => {
+ navigateWithParams((params) => {
+ params.delete(LOG_ID_QUERY_PARAM);
+ params.delete(SESSION_ID_QUERY_PARAM);
+ });
+ }, []);
+
+ return {
+ logId: searchParams?.get(LOG_ID_QUERY_PARAM) ?? null,
+ sessionId: searchParams?.get(SESSION_ID_QUERY_PARAM) ?? null,
+ openLog,
+ openSession,
+ selectLog,
+ close,
+ };
+}