mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
fix(ui): jump straight to the last Request Logs page instead of advancing one page
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
8a4fae0e17
commit
857b9ad7d3
4 changed files with 91 additions and 7 deletions
|
|
@ -2944,7 +2944,10 @@ async def _ui_session_grouped_spend_logs(
|
|||
next ``page_size`` sessions ordered by ``(MAX(startTime), session_key,
|
||||
api_key)``, resumed from the ``session_cursor`` keyset
|
||||
``'<last_activity>|<api_key>|<session_key>'`` instead of an OFFSET, so
|
||||
page depth does not degrade the query plan. Each session is represented
|
||||
page depth does not degrade the query plan. A request for ``page > 1``
|
||||
without a cursor (the UI jumping straight to the last page, or back to a
|
||||
page it never walked through) falls back to ``OFFSET (page - 1) *
|
||||
page_size``, bounded by the capped total. Each session is represented
|
||||
by its newest non-MCP row, enriched by ``_build_ui_spend_logs_response``
|
||||
exactly like the flat listing, and the response carries
|
||||
``next_session_cursor`` / ``has_more`` while ``total`` counts sessions
|
||||
|
|
@ -2963,6 +2966,8 @@ async def _ui_session_grouped_spend_logs(
|
|||
)
|
||||
cursor_params: Final[tuple[object, ...]] = cursor if cursor else ()
|
||||
limit_index: Final = next_param_index + len(cursor_params)
|
||||
offset_params: Final[tuple[int, ...]] = ((page - 1) * page_size,) if cursor is None and page > 1 else ()
|
||||
offset_clause: Final = f"OFFSET ${limit_index + 1}" if offset_params else ""
|
||||
|
||||
page_query: Final = f"""
|
||||
SELECT {_SESSION_KEY_EXPR} AS session_key,
|
||||
|
|
@ -2973,10 +2978,10 @@ async def _ui_session_grouped_spend_logs(
|
|||
GROUP BY {_SESSION_GROUP_KEY_SQL}
|
||||
{having_clause}
|
||||
ORDER BY MAX("startTime") {direction}, {_SESSION_KEY_EXPR} {direction}, api_key {direction}
|
||||
LIMIT ${limit_index}
|
||||
LIMIT ${limit_index} {offset_clause}
|
||||
"""
|
||||
page_rows: Final[Sequence[_SessionPageRow]] = await _query_raw(
|
||||
prisma_client, page_query, *sql_params, *cursor_params, page_size + 1
|
||||
prisma_client, page_query, *sql_params, *cursor_params, page_size + 1, *offset_params
|
||||
)
|
||||
|
||||
has_more: Final = len(page_rows) > page_size
|
||||
|
|
|
|||
|
|
@ -6742,6 +6742,48 @@ async def test_ui_view_spend_logs_group_by_session_cursor_page(client, monkeypat
|
|||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_group_by_session_jumps_to_page_without_cursor(client, monkeypatch):
|
||||
"""page > 1 with no session_cursor (the UI's last-page jump) skips (page - 1) * page_size sessions by OFFSET."""
|
||||
page_rows = [_session_page_row("sess-3", "2026-08-29 06:00:00")]
|
||||
reps = [_session_representative_row("req-3", "sess-3")]
|
||||
mock_prisma = _session_grouped_mock_prisma(page_rows, 60, reps)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.spend_tracking.spend_management_endpoints._is_admin_view_safe",
|
||||
lambda user_api_key_dict: True,
|
||||
)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
|
||||
)
|
||||
try:
|
||||
start_date, end_date = _default_date_range()
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"group_by_session": "true",
|
||||
"page": 3,
|
||||
"page_size": 25,
|
||||
},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["page"] == 3
|
||||
assert data["has_more"] is False
|
||||
assert [row["request_id"] for row in data["data"]] == ["req-3"]
|
||||
|
||||
page_query_call = mock_prisma.db.query_raw.await_args_list[0]
|
||||
page_query_sql = page_query_call.args[0]
|
||||
assert "HAVING" not in page_query_sql
|
||||
assert "OFFSET" in page_query_sql
|
||||
assert page_query_call.args[-2:] == (26, 50), "LIMIT page_size + 1 then OFFSET (page - 1) * page_size"
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_group_by_session_offset_for_non_starttime_sort(
|
||||
client, monkeypatch
|
||||
|
|
|
|||
|
|
@ -258,6 +258,44 @@ describe("RequestLogsPanel", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("jumps straight to the last page without a cursor when the last-page button is clicked", async () => {
|
||||
const firstPage = Array.from({ length: 25 }, (_, index) => logEntry({ request_id: `req-${index}` }));
|
||||
const lastPage = Array.from({ length: 10 }, (_, index) => logEntry({ request_id: `req-last-${index}` }));
|
||||
vi.mocked(uiSpendLogsCall).mockImplementation(async ({ page }) =>
|
||||
page === 3
|
||||
? {
|
||||
data: lastPage,
|
||||
total: 60,
|
||||
page: 3,
|
||||
page_size: 25,
|
||||
total_pages: 3,
|
||||
next_session_cursor: null,
|
||||
has_more: false,
|
||||
}
|
||||
: {
|
||||
data: firstPage,
|
||||
total: 60,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
total_pages: 3,
|
||||
next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1",
|
||||
has_more: true,
|
||||
},
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => expect(row("req-0")).not.toBeNull());
|
||||
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 3");
|
||||
fireEvent.click(screen.getByTestId("pagination-last"));
|
||||
|
||||
await waitFor(() => expect(row("req-last-0")).not.toBeNull());
|
||||
expect(lastCall()?.page).toBe(3);
|
||||
expect(lastCall()?.params?.session_cursor).toBeUndefined();
|
||||
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 3 of 3");
|
||||
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 51-60 of 60");
|
||||
expect(vi.mocked(uiSpendLogsCall).mock.calls.filter(([options]) => options.page === 2)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("drops the cursor and returns to the first page when a filter changes", async () => {
|
||||
const firstPage = Array.from({ length: 50 }, (_, index) => logEntry({ request_id: `req-${index}` }));
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -209,15 +209,14 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
setPagination({ ...requested, pageIndex: 0 });
|
||||
return;
|
||||
}
|
||||
if (requested.pageIndex <= pagination.pageIndex) {
|
||||
if (requested.pageIndex !== pagination.pageIndex + 1) {
|
||||
setPagination(requested);
|
||||
return;
|
||||
}
|
||||
const nextCursor = filteredLogs.next_session_cursor;
|
||||
if (!nextCursor || logsQuery.isPlaceholderData) return;
|
||||
const nextPageIndex = pagination.pageIndex + 1;
|
||||
setSessionCursors((previous) => ({ ...previous, [nextPageIndex]: nextCursor }));
|
||||
setPagination({ ...requested, pageIndex: nextPageIndex });
|
||||
setSessionCursors((previous) => ({ ...previous, [requested.pageIndex]: nextCursor }));
|
||||
setPagination(requested);
|
||||
},
|
||||
[usesSessionCursor, pagination, filteredLogs.next_session_cursor, logsQuery.isPlaceholderData],
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue