mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
feat(ui): shareable log links via log_id query param on the logs page (#34879)
* feat(ui): shareable log links via log_id query param on the logs page Clicking a log row now writes ?log_id=<request_id> to the URL, closing the drawer removes it, and loading the logs page with ?log_id= opens the drawer for that log. When the log is not in the loaded page, it is fetched by request_id (the backend already drops the date window for id lookups), so links keep working for logs of any age. Drawer open state derives from the URL, mirroring the models page ?model= pattern. * fix(ui): close the log drawer on browser back after opening via session id Session opens now write ?session_id= to the URL instead of holding local state, so back removes both params and the drawer closes (Greptile P1). Session views become shareable links as a side effect. In-drawer log switching now replaces the history entry instead of pushing, so back always closes the drawer in one step rather than replaying every viewed log. * fix(proxy): scope /spend/logs/session/ui to the requesting user's visible logs Non-admin callers now only receive session rows they could already see on /spend/logs/ui: their own logs plus logs of teams where they hold the spend-logs permission. Previously any authenticated user could read any session's log metadata by id, which shareable ?session_id= links made trivial to trigger. Admin views are unchanged. Also, clicking a log row now clears a lingering ?session_id= from the URL so the drawer shows the clicked log instead of a stale session (Greptile P1).
This commit is contained in:
parent
9b7a6b9b90
commit
fdea50daa2
6 changed files with 529 additions and 41 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <div data-testid="log-details-drawer">{open ? "open" : "closed"}</div>;
|
||||
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 (
|
||||
<div data-testid="log-details-drawer" data-log-id={logEntry?.request_id ?? ""} data-session-id={sessionId ?? ""}>
|
||||
{open ? "open" : "closed"}
|
||||
<button type="button" onClick={onClose}>
|
||||
close-drawer
|
||||
</button>
|
||||
<button type="button" onClick={() => nextLog && onSelectLog?.(nextLog)}>
|
||||
select-next-log
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("next/navigation")>();
|
||||
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>): 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=<request_id> to the URL and opens the drawer", async () => {
|
||||
const user = userEvent.setup();
|
||||
respondWith([logEntry({ request_id: "req-1" })]);
|
||||
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
|
||||
|
||||
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(<RequestLogsPanel {...defaultProps} />);
|
||||
|
||||
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(<RequestLogsPanel {...defaultProps} />);
|
||||
|
||||
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(<RequestLogsPanel {...defaultProps} />);
|
||||
|
||||
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(<RequestLogsPanel {...defaultProps} />);
|
||||
|
||||
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(<RequestLogsPanel {...defaultProps} />);
|
||||
|
||||
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(<RequestLogsPanel {...defaultProps} />);
|
||||
|
||||
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(<RequestLogsPanel {...defaultProps} />);
|
||||
|
||||
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(<RequestLogsPanel {...defaultProps} />);
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
logId: urlLogId,
|
||||
sessionId: urlSessionId,
|
||||
openLog,
|
||||
openSession,
|
||||
selectLog,
|
||||
close: closeUrlLog,
|
||||
} = useLogDetailRouting();
|
||||
|
||||
const [isLiveTail, setIsLiveTail] = useState<boolean>(() => {
|
||||
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<LogEntry | null> = {
|
||||
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<LogEntry | null>(() => {
|
||||
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<string | null>(() => {
|
||||
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<LogEntry[]>(() => {
|
||||
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,
|
|||
|
||||
<LogDetailsDrawer
|
||||
open={isDrawerOpen}
|
||||
onClose={() => {
|
||||
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")}
|
||||
/>
|
||||
</AutoRouterModelGroupsProvider>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue