mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
feat(ui): add shareable deep link to log request/session in logs drawer
Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
parent
1fa200123f
commit
7be2168bca
5 changed files with 255 additions and 10 deletions
|
|
@ -0,0 +1,69 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DrawerHeader } from "./DrawerHeader";
|
||||
import { LogEntry } from "../columns";
|
||||
|
||||
const makeLog = (overrides: Partial<LogEntry> = {}): LogEntry =>
|
||||
({
|
||||
request_id: "req-123",
|
||||
api_key: "",
|
||||
team_id: "",
|
||||
model: "gpt-4o",
|
||||
model_id: "",
|
||||
call_type: "acompletion",
|
||||
custom_llm_provider: "",
|
||||
spend: 0,
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
startTime: "2026-07-08T10:00:00.000Z",
|
||||
endTime: "2026-07-08T10:00:01.000Z",
|
||||
cache_hit: "false",
|
||||
messages: [],
|
||||
response: {},
|
||||
metadata: {},
|
||||
...overrides,
|
||||
}) as LogEntry;
|
||||
|
||||
const renderHeader = (shareUrl: string) =>
|
||||
render(
|
||||
<DrawerHeader
|
||||
log={makeLog()}
|
||||
onClose={() => {}}
|
||||
onPrevious={() => {}}
|
||||
onNext={() => {}}
|
||||
statusLabel="Success"
|
||||
statusColor="success"
|
||||
environment="default"
|
||||
shareUrl={shareUrl}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("DrawerHeader share link", () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
writeText.mockClear();
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("copies the share url to the clipboard when the copy-link button is clicked", async () => {
|
||||
const shareUrl = "https://proxy.example.com/ui/?page=logs&request_id=req-123";
|
||||
renderHeader(shareUrl);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy link to this log" }));
|
||||
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith(shareUrl));
|
||||
});
|
||||
|
||||
it("shows a 'Copied!' tooltip after copying", async () => {
|
||||
renderHeader("https://proxy.example.com/ui/?request_id=req-123");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy link to this log" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "check" })).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Button, Space, Tag, Tooltip, Typography } from "antd";
|
||||
import { CloseOutlined, UpOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import { CheckOutlined, CloseOutlined, DownOutlined, LinkOutlined, UpOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import moment from "moment";
|
||||
import { LogEntry } from "../columns";
|
||||
import { getProviderLogoAndName } from "../../provider_info_helpers";
|
||||
|
|
@ -25,6 +26,7 @@ interface DrawerHeaderProps {
|
|||
statusLabel: string;
|
||||
statusColor: "error" | "success";
|
||||
environment: string;
|
||||
shareUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -39,6 +41,7 @@ export function DrawerHeader({
|
|||
statusLabel,
|
||||
statusColor,
|
||||
environment,
|
||||
shareUrl,
|
||||
}: DrawerHeaderProps) {
|
||||
const provider = log.custom_llm_provider || "";
|
||||
const providerInfo = provider ? getProviderLogoAndName(provider) : null;
|
||||
|
|
@ -66,7 +69,7 @@ export function DrawerHeader({
|
|||
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: SPACING_MEDIUM }}
|
||||
>
|
||||
<RequestIdSection requestId={log.request_id} />
|
||||
<NavigationSection onPrevious={onPrevious} onNext={onNext} onClose={onClose} />
|
||||
<NavigationSection onPrevious={onPrevious} onNext={onNext} onClose={onClose} shareUrl={shareUrl} />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Status + Env + Timestamp */}
|
||||
|
|
@ -148,10 +151,12 @@ function NavigationSection({
|
|||
onPrevious,
|
||||
onNext,
|
||||
onClose,
|
||||
shareUrl,
|
||||
}: {
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onClose: () => void;
|
||||
shareUrl: string;
|
||||
}) {
|
||||
const keyboardShortcutStyle = {
|
||||
border: "1px solid #d9d9d9",
|
||||
|
|
@ -165,6 +170,7 @@ function NavigationSection({
|
|||
|
||||
return (
|
||||
<Space size={SPACING_SMALL} split={<div style={{ width: 1, height: 20, background: COLOR_BORDER }} />}>
|
||||
<ShareLinkButton shareUrl={shareUrl} />
|
||||
<Button type="text" size="small" onClick={onPrevious}>
|
||||
<UpOutlined />
|
||||
<span style={keyboardShortcutStyle}>K</span>
|
||||
|
|
@ -180,6 +186,37 @@ function NavigationSection({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy-link button that yields a shareable deep link reopening this exact
|
||||
* request / session in the log drawer.
|
||||
*/
|
||||
function ShareLinkButton({ shareUrl }: { shareUrl: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!shareUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
/* clipboard unavailable in non-secure contexts */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={copied ? "Copied!" : "Copy link to this log"}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={copied ? <CheckOutlined /> : <LinkOutlined />}
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy link to this log"
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status bar with tags and timestamp
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -289,6 +289,18 @@ export function LogDetailsDrawer({
|
|||
}
|
||||
};
|
||||
|
||||
const shareUrl = useMemo(() => {
|
||||
if (typeof window === "undefined" || !currentLog) return "";
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("request_id", currentLog.request_id);
|
||||
if (isSessionMode && sessionId) {
|
||||
url.searchParams.set("session_id", sessionId);
|
||||
} else {
|
||||
url.searchParams.delete("session_id");
|
||||
}
|
||||
return url.toString();
|
||||
}, [currentLog, isSessionMode, sessionId]);
|
||||
|
||||
if (!currentLog || !enrichedLog) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -457,6 +469,7 @@ export function LogDetailsDrawer({
|
|||
statusLabel={statusLabel}
|
||||
statusColor={statusColor}
|
||||
environment={environment}
|
||||
shareUrl={shareUrl}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<LogDetailContent
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import moment from "moment";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import SpendLogsTable from "./index";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import { uiSpendLogsCall } from "../networking";
|
||||
|
|
@ -43,6 +43,17 @@ vi.mock("../key_team_helpers/filter_helpers", () => ({
|
|||
fetchAllTeams: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("./LogDetailsDrawer", () => ({
|
||||
LogDetailsDrawer: (props: { open: boolean; logEntry: { request_id?: string } | null; sessionId?: string | null }) =>
|
||||
props.open ? (
|
||||
<div
|
||||
data-testid="log-drawer"
|
||||
data-request-id={props.logEntry?.request_id ?? ""}
|
||||
data-session-id={props.sessionId ?? ""}
|
||||
/>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
describe("SpendLogsTable", () => {
|
||||
const defaultProps = {
|
||||
accessToken: "test-token",
|
||||
|
|
@ -119,6 +130,75 @@ describe("SpendLogsTable", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("deep linking", () => {
|
||||
const makeRow = (overrides: Record<string, unknown>) => ({
|
||||
request_id: "req-abc",
|
||||
api_key: "",
|
||||
team_id: "",
|
||||
model: "gpt-4o",
|
||||
model_id: "",
|
||||
call_type: "acompletion",
|
||||
spend: 0,
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
startTime: "2026-07-08T10:00:00.000Z",
|
||||
endTime: "2026-07-08T10:00:01.000Z",
|
||||
cache_hit: "false",
|
||||
messages: [],
|
||||
response: {},
|
||||
metadata: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockLogs = (data: Array<Record<string, unknown>>) => {
|
||||
vi.mocked(useLogFilterLogic).mockReturnValue({
|
||||
logsQuery: { isLoading: false, isFetching: false, refetch: vi.fn() },
|
||||
filteredLogs: { data, total: data.length, page: 1, page_size: 50, total_pages: 1 },
|
||||
allTeams: [],
|
||||
handleFilterChange: vi.fn(),
|
||||
handleFilterReset: mockHandleFilterResetFromHook,
|
||||
} as unknown as ReturnType<typeof useLogFilterLogic>);
|
||||
};
|
||||
|
||||
const originalUrl = window.location.href;
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, "", originalUrl);
|
||||
});
|
||||
|
||||
it("auto-opens the drawer for the matching request when ?request_id= is present", async () => {
|
||||
window.history.replaceState({}, "", "/?page=logs&request_id=req-abc");
|
||||
mockLogs([makeRow({ request_id: "req-other" }), makeRow({ request_id: "req-abc" })]);
|
||||
|
||||
renderWithProviders(<SpendLogsTable {...defaultProps} />);
|
||||
|
||||
const drawer = await screen.findByTestId("log-drawer");
|
||||
expect(drawer).toHaveAttribute("data-request-id", "req-abc");
|
||||
expect(drawer).toHaveAttribute("data-session-id", "");
|
||||
});
|
||||
|
||||
it("auto-opens the session drawer and selects the request when ?session_id= is present", async () => {
|
||||
window.history.replaceState({}, "", "/?page=logs&session_id=sess-1&request_id=req-child");
|
||||
mockLogs([makeRow({ request_id: "req-repr", session_id: "sess-1", session_total_count: 3 })]);
|
||||
|
||||
renderWithProviders(<SpendLogsTable {...defaultProps} />);
|
||||
|
||||
const drawer = await screen.findByTestId("log-drawer");
|
||||
expect(drawer).toHaveAttribute("data-session-id", "sess-1");
|
||||
expect(drawer).toHaveAttribute("data-request-id", "req-child");
|
||||
});
|
||||
|
||||
it("does not open the drawer when there is no deep-link param", async () => {
|
||||
window.history.replaceState({}, "", "/?page=logs");
|
||||
mockLogs([makeRow({ request_id: "req-abc" })]);
|
||||
|
||||
renderWithProviders(<SpendLogsTable {...defaultProps} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument());
|
||||
expect(screen.queryByTestId("log-drawer")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Quick Select time range", () => {
|
||||
// uiSpendLogsCall fires from the real useLogFilterLogic query, so restore it here.
|
||||
beforeEach(async () => {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import AuditLogs from "./audit_logs";
|
|||
import { createColumns, LogEntry, type LogsSortField } from "./columns";
|
||||
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
|
||||
import { getLogFilterOptions } from "./filter_options";
|
||||
import { useLogFilterLogic, defaultFilters, type LogFilterState } from "./log_filter_logic";
|
||||
import { useLogFilterLogic, defaultFilters, FILTER_KEYS, type LogFilterState } from "./log_filter_logic";
|
||||
import { LogDetailsDrawer } from "./LogDetailsDrawer";
|
||||
import { LogsTableToolbar } from "./LogsTableToolbar";
|
||||
import { DataTable } from "./table";
|
||||
|
|
@ -26,17 +26,37 @@ interface SpendLogsTableProps {
|
|||
premiumUser: boolean;
|
||||
}
|
||||
|
||||
function getDeepLinkFromLocation(): { requestId?: string; sessionId?: string } | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const requestId = params.get("request_id") || undefined;
|
||||
const sessionId = params.get("session_id") || undefined;
|
||||
return requestId || sessionId ? { requestId, sessionId } : null;
|
||||
}
|
||||
|
||||
export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(50);
|
||||
|
||||
// Deep link: a shared ?request_id=/&session_id= URL reopens that exact
|
||||
// request/session in the drawer on load, until the user closes it. Read once
|
||||
// at init so the initial filter/time-window target the shared log.
|
||||
const [deepLink] = useState<{ requestId?: string; sessionId?: string } | null>(getDeepLinkFromLocation);
|
||||
const [deepLinkDismissed, setDeepLinkDismissed] = useState(false);
|
||||
|
||||
// New state variables for Start and End Time
|
||||
const [startTime, setStartTime] = useState<string>(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"));
|
||||
const [startTime, setStartTime] = useState<string>(() =>
|
||||
(deepLink ? moment().subtract(1, "year") : moment().subtract(24, "hours")).format("YYYY-MM-DDTHH:mm"),
|
||||
);
|
||||
const [endTime, setEndTime] = useState<string>(moment().format("YYYY-MM-DDTHH:mm"));
|
||||
|
||||
const [isCustomDate, setIsCustomDate] = useState(false);
|
||||
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
|
||||
const [isCustomDate, setIsCustomDate] = useState(() => !!deepLink);
|
||||
const [filters, setFilters] = useState<LogFilterState>(() => {
|
||||
if (deepLink?.sessionId) return { ...defaultFilters, [FILTER_KEYS.SESSION_ID]: deepLink.sessionId };
|
||||
if (deepLink?.requestId) return { ...defaultFilters, [FILTER_KEYS.REQUEST_ID]: deepLink.requestId };
|
||||
return defaultFilters;
|
||||
});
|
||||
const [selectedKeyInfo, setSelectedKeyInfo] = useState<KeyResponse | null>(null);
|
||||
const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState<string | null>(null);
|
||||
const [filterByCurrentUser, setFilterByCurrentUser] = useState(userRole && internalUserRoles.includes(userRole));
|
||||
|
|
@ -202,6 +222,31 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
|
|||
);
|
||||
}, [filteredLogs.data, searchTerm]);
|
||||
|
||||
// Resolve a shared deep link (?request_id=/&session_id=) against the loaded
|
||||
// rows. Derived (not stored) so it needs no effect; the filter set at init
|
||||
// guarantees the target row is present once the query resolves.
|
||||
const deepLinkResolved = useMemo(() => {
|
||||
if (!deepLink || deepLinkDismissed) return null;
|
||||
const { requestId, sessionId } = deepLink;
|
||||
if (sessionId) {
|
||||
const row =
|
||||
filteredData.find((log) => log.session_id === sessionId && (!requestId || log.request_id === requestId)) ||
|
||||
filteredData.find((log) => log.session_id === sessionId);
|
||||
if (!row) return null;
|
||||
return { log: requestId ? { ...row, request_id: requestId } : row, sessionId };
|
||||
}
|
||||
if (requestId) {
|
||||
const row = filteredData.find((log) => log.request_id === requestId);
|
||||
if (!row) return null;
|
||||
return { log: row, sessionId: null as string | null };
|
||||
}
|
||||
return null;
|
||||
}, [deepLink, deepLinkDismissed, filteredData]);
|
||||
|
||||
const drawerOpen = isDrawerOpen || deepLinkResolved !== null;
|
||||
const drawerLog = isDrawerOpen ? selectedLog : deepLinkResolved?.log ?? selectedLog;
|
||||
const drawerSessionId = isDrawerOpen ? selectedSessionId : deepLinkResolved?.sessionId ?? selectedSessionId;
|
||||
|
||||
// Keep the Fetch button busy until the table has actually committed the new
|
||||
// rows. `keepPreviousData` leaves logsQuery.isLoading false on refetch, so
|
||||
// without this the button clears while stale rows are still on screen.
|
||||
|
|
@ -316,13 +361,14 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
|
|||
|
||||
{/* Log Details Drawer */}
|
||||
<LogDetailsDrawer
|
||||
open={isDrawerOpen}
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
setIsDrawerOpen(false);
|
||||
setSelectedSessionId(null);
|
||||
setDeepLinkDismissed(true);
|
||||
}}
|
||||
logEntry={selectedLog}
|
||||
sessionId={selectedSessionId}
|
||||
logEntry={drawerLog}
|
||||
sessionId={drawerSessionId}
|
||||
accessToken={accessToken}
|
||||
allLogs={filteredData}
|
||||
onSelectLog={setSelectedLog}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue