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, + }; +}