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:
Devin AI 2026-07-09 22:59:19 +00:00
parent 1fa200123f
commit 7be2168bca
5 changed files with 255 additions and 10 deletions

View file

@ -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());
});
});

View file

@ -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
*/

View file

@ -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

View file

@ -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 () => {

View file

@ -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}