diff --git a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py index f4c93432943..f4bfca9e8e8 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -7178,8 +7178,38 @@ async def test_ui_view_spend_logs_grouped_exclusion_reaches_page_count_and_repre page_rows = [ _session_page_row("sess-1", "2026-08-29 10:00:00"), _session_page_row("sess-2", "2026-08-29 09:00:00"), + _session_page_row("sess-3", "2026-08-29 08:00:00"), ] - mock_prisma = _session_grouped_mock_prisma(page_rows, 2, [_session_representative_row("req-1", "sess-1")]) + session_aliases = {"sess-1": "noisy", "sess-2": "quiet", "sess-3": "quiet"} + representatives = [] + for request_id, session_id in (("req-1", "sess-1"), ("req-2", "sess-2"), ("req-3", "sess-3")): + representative = _session_representative_row(request_id, session_id) + representative["metadata"] = {"user_api_key_alias": session_aliases[session_id]} + representatives.append(representative) + + async def mock_query_raw(sql_query, *params): + excluded_alias_match = re.search(r"user_api_key_alias' IS DISTINCT FROM \$(\d+)", sql_query) + excluded_alias = params[int(excluded_alias_match.group(1)) - 1] if excluded_alias_match else None + visible_session_ids = {session_id for session_id, alias in session_aliases.items() if alias != excluded_alias} + if "COUNT(*) AS total_count" in sql_query: + return [{"total_count": len(visible_session_ids)}] + if "DISTINCT ON" in sql_query: + requested_sessions = set(params[-2]) + requested_api_keys = set(params[-1]) + return [ + representative + for representative in representatives + if representative["session_id"] in visible_session_ids + and representative["session_id"] in requested_sessions + and representative["api_key"] in requested_api_keys + ] + if "COALESCE(SUM(spend)" in sql_query: + return [] + return [row for row in page_rows if row["session_key"] in visible_session_ids] + + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.query_raw = AsyncMock(side_effect=mock_query_raw) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) monkeypatch.setattr( "litellm.proxy.spend_tracking.spend_management_endpoints._is_admin_view_safe", @@ -7203,6 +7233,7 @@ async def test_ui_view_spend_logs_grouped_exclusion_reaches_page_count_and_repre ) assert response.status_code == 200, response.text assert response.json()["total"] == 2 + assert [row["request_id"] for row in response.json()["data"]] == ["req-2"] queries = [call.args for call in mock_prisma.db.query_raw.await_args_list] assert len(queries) >= 3 for sql, *params in queries[:3]: diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx index 16f6ce77866..55f119baadd 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx @@ -339,6 +339,40 @@ describe("RequestLogsPanel", () => { }); }); + it("drops the cursor and returns to the first page when the alias changes through URL navigation", async () => { + const firstPage = Array.from({ length: 50 }, (_, index) => logEntry({ request_id: `req-${index}` })); + vi.mocked(uiSpendLogsCall).mockImplementation(async ({ page }) => ({ + data: firstPage, + total: 80, + page: page ?? 1, + page_size: 50, + total_pages: 2, + next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1", + has_more: true, + })); + const tree = (searchParams: string) => ( + + + + + + ); + const view = render(tree("")); + + await waitFor(() => expect(row("req-0")).not.toBeNull()); + fireEvent.click(screen.getByTestId("pagination-next")); + await waitFor(() => expect(lastCall()?.page).toBe(2)); + + view.rerender(tree("exclude_key_alias=noisy")); + + await waitFor(() => { + const call = lastCall(); + expect(call?.page).toBe(1); + expect(call?.params?.exclude_key_alias).toBe("noisy"); + expect(call?.params?.session_cursor).toBeUndefined(); + }); + }); + it("ignores another next click while the next page is still fetching", async () => { const firstPage = Array.from({ length: 50 }, (_, index) => logEntry({ request_id: `req-${index}` })); const firstResponse = { diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx index c25858f34c0..70988223027 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx @@ -56,6 +56,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, { key_alias: parseAsString, exclude_key_alias: parseAsString }, { history: "push" }, ); + const [observedAliasParams, setObservedAliasParams] = useState({ key_alias, exclude_key_alias }); const columnFilters = useMemo(() => { const aliasFilters: ColumnFiltersState = []; if (exclude_key_alias) { @@ -70,6 +71,13 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, columnFiltersRef.current = columnFilters; }, [columnFilters]); const [sessionCursors, setSessionCursors] = useState>({}); + const aliasParamsChanged = + observedAliasParams.key_alias !== key_alias || observedAliasParams.exclude_key_alias !== exclude_key_alias; + const effectivePagination = useMemo( + () => (aliasParamsChanged ? { ...pagination, pageIndex: 0 } : pagination), + [aliasParamsChanged, pagination], + ); + const effectiveSessionCursors = aliasParamsChanged ? {} : sessionCursors; const [timeRange, setTimeRange] = useState(defaultLogsTimeRange); const { startTime, endTime, isCustomDate } = timeRange; @@ -124,10 +132,10 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, excludeInternalHealthChecks, startTime, endTime, - pagination, + pagination: effectivePagination, isCustomDate, sorting, - sessionCursors, + sessionCursors: effectiveSessionCursors, }); // Follow the table's own last fetch so a live-tail refresh carries the filter @@ -192,7 +200,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, const isDrawerOpen = displayLog !== null || displaySessionId !== null; const rows: LogEntry[] = filteredLogs.data; - const rowsThroughThisPage = pagination.pageIndex * pagination.pageSize + rows.length; + const rowsThroughThisPage = effectivePagination.pageIndex * effectivePagination.pageSize + rows.length; const isLastPage = filteredLogs.has_more === false || (filteredLogs.has_more === undefined && rows.length < pagination.pageSize); const rowCount = isLastPage ? rowsThroughThisPage : Math.max(filteredLogs.total, rowsThroughThisPage); @@ -241,24 +249,37 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, [setAliasParams], ); - const resetToFirstPage = useCallback(() => { - setSessionCursors({}); - setPagination((previous) => ({ ...previous, pageIndex: 0 })); - }, []); + const resetToFirstPage = useCallback( + (nextAliasParams?: { key_alias: string | null; exclude_key_alias: string | null }) => { + if (nextAliasParams) { + setObservedAliasParams(nextAliasParams); + } + setSessionCursors({}); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + }, + [], + ); + + useEffect(() => { + if (!aliasParamsChanged) return; + // Browser history changes arrive outside the table event handlers, so local cursor state must follow the URL. + // eslint-disable-next-line react-hooks/set-state-in-effect + resetToFirstPage({ key_alias, exclude_key_alias }); + }, [aliasParamsChanged, key_alias, exclude_key_alias, resetToFirstPage]); const handlePaginationChange = useCallback>( (updaterOrValue) => { - const requested = typeof updaterOrValue === "function" ? updaterOrValue(pagination) : updaterOrValue; + const requested = typeof updaterOrValue === "function" ? updaterOrValue(effectivePagination) : updaterOrValue; if (!usesSessionCursor) { setPagination(requested); return; } - if (requested.pageSize !== pagination.pageSize) { + if (requested.pageSize !== effectivePagination.pageSize) { setSessionCursors({}); setPagination({ ...requested, pageIndex: 0 }); return; } - if (requested.pageIndex !== pagination.pageIndex + 1) { + if (requested.pageIndex !== effectivePagination.pageIndex + 1) { setPagination(requested); return; } @@ -267,7 +288,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, setSessionCursors((previous) => ({ ...previous, [requested.pageIndex]: nextCursor })); setPagination(requested); }, - [usesSessionCursor, pagination, filteredLogs.next_session_cursor, logsQuery.isPlaceholderData], + [usesSessionCursor, effectivePagination, filteredLogs.next_session_cursor, logsQuery.isPlaceholderData], ); const handleExcludeInternalHealthChecksChange = useCallback( @@ -345,7 +366,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, rowCount={rowCount} isLoading={logsQuery.isLoading} isRefreshing={logsQuery.isFetching} - pagination={pagination} + pagination={effectivePagination} onPaginationChange={handlePaginationChange} sorting={sorting} onSortingChange={handleSortingChange} @@ -363,7 +384,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, - {isLiveTail && pagination.pageIndex === 0 && ( + {isLiveTail && effectivePagination.pageIndex === 0 && ( Refreshing every 15s