This commit is contained in:
devin-ai-integration[bot] 2026-09-12 08:23:12 -04:00 committed by GitHub
commit 4c958473f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 230 additions and 13 deletions

View file

@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useInfiniteUsers, useUserLookup } from "./useUsers";
import { useInfiniteUsers, useUserEmailLookup, useUserLookup } from "./useUsers";
import { userListCall } from "@/components/networking";
import type { UserListResponse } from "@/components/networking";
@ -235,7 +235,7 @@ describe("useInfiniteUsers", () => {
});
it("should execute query for each admin role", async () => {
const adminRoles = ["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer", "org_admin"];
const adminRoles = ["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer", "org_admin", "Org Admin"];
for (const role of adminRoles) {
vi.clearAllMocks();
@ -335,3 +335,72 @@ describe("useUserLookup", () => {
expect(userListCall).not.toHaveBeenCalled();
});
});
describe("useUserEmailLookup", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue(DEFAULT_AUTH);
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("fetches the distinct ids in one call and maps each id to its email", async () => {
const response = buildUserListResponse(1, 1, 2);
vi.mocked(userListCall).mockResolvedValue(response);
const { result } = renderHook(() => useUserEmailLookup(["user-1-1", "user-1-0", "user-1-1", ""]), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(userListCall).toHaveBeenCalledTimes(1);
expect(userListCall).toHaveBeenCalledWith("test-access-token", ["user-1-0", "user-1-1"], 1, 2);
expect(result.current.data).toEqual({
"user-1-0": "user-1-0@example.com",
"user-1-1": "user-1-1@example.com",
});
});
it("omits users that have no email so callers fall back to the id", async () => {
const response = buildUserListResponse(1, 1, 2);
vi.mocked(userListCall).mockResolvedValue({
...response,
users: [{ ...response.users[0], user_email: "" }, response.users[1]],
});
const { result } = renderHook(() => useUserEmailLookup(["user-1-0", "user-1-1"]), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ "user-1-1": "user-1-1@example.com" });
});
it("does not query with no ids", async () => {
const { result } = renderHook(() => useUserEmailLookup([]), { wrapper });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(result.current.fetchStatus).toBe("idle");
expect(userListCall).not.toHaveBeenCalled();
});
it("queries for the formatted Org Admin session role", async () => {
mockUseAuthorized.mockReturnValue({ ...DEFAULT_AUTH, userRole: "Org Admin" });
vi.mocked(userListCall).mockResolvedValue(buildUserListResponse(1, 1, 1));
const { result } = renderHook(() => useUserEmailLookup(["user-1-0"]), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ "user-1-0": "user-1-0@example.com" });
});
it("does not query for a non-admin role", async () => {
mockUseAuthorized.mockReturnValue({ ...DEFAULT_AUTH, userRole: "Internal User" });
const { result } = renderHook(() => useUserEmailLookup(["user-1-0"]), { wrapper });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(result.current.fetchStatus).toBe("idle");
expect(userListCall).not.toHaveBeenCalled();
});
});

View file

@ -1,7 +1,7 @@
import { userListCall, UserInfo, UserListResponse } from "@/components/networking";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { all_admin_roles } from "@/utils/roles";
import { canListUsers } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const infiniteUsersKeys = createQueryKeys("infiniteUsers");
@ -34,7 +34,25 @@ export const useInfiniteUsers = (pageSize: number = DEFAULT_PAGE_SIZE, searchEma
}
return undefined;
},
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!),
enabled: Boolean(accessToken) && canListUsers(userRole),
});
};
const USER_LIST_MAX_PAGE_SIZE = 100;
export const useUserEmailLookup = (userIds: readonly string[]) => {
const { accessToken, userRole } = useAuthorized();
const distinctIds = Array.from(new Set(userIds.filter((id) => id !== ""))).sort();
return useQuery<Record<string, string>>({
queryKey: userLookupKeys.list({ filters: { ids: JSON.stringify(distinctIds) } }),
queryFn: async () => {
const ids = distinctIds.slice(0, USER_LIST_MAX_PAGE_SIZE);
const response = await userListCall(accessToken!, ids, 1, ids.length);
return Object.fromEntries(
response.users.filter((user) => Boolean(user.user_email)).map((user) => [user.user_id, user.user_email]),
);
},
enabled: Boolean(accessToken) && distinctIds.length > 0 && canListUsers(userRole),
});
};
@ -46,6 +64,6 @@ export const useUserLookup = (userId: string | null) => {
const response = await userListCall(accessToken!, [userId!], 1, 1);
return response.users.find((user) => user.user_id === userId) ?? null;
},
enabled: Boolean(accessToken) && Boolean(userId) && all_admin_roles.includes(userRole!),
enabled: Boolean(accessToken) && Boolean(userId) && canListUsers(userRole),
});
};

View file

@ -56,6 +56,29 @@ describe("LogDetailContent", () => {
expect(screen.getByText("completion")).toBeInTheDocument();
});
it("shows the requesting user's email and id in Request Details when the email is resolved", () => {
render(
<LogDetailContent logEntry={createLogEntry({ user: "106514937785257944828" })} userEmail="alice@example.com" />,
);
expect(screen.getByText("User")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
expect(screen.getByText("106514937785257944828")).toBeInTheDocument();
});
it("falls back to the user id in Request Details when no email is resolved", () => {
render(<LogDetailContent logEntry={createLogEntry({ user: "106514937785257944828" })} />);
expect(screen.getByText("User")).toBeInTheDocument();
expect(screen.getByText("106514937785257944828")).toBeInTheDocument();
});
it("omits the User row when the log has no internal user", () => {
render(<LogDetailContent logEntry={createLogEntry({ user: undefined })} />);
expect(screen.queryByText("User")).not.toBeInTheDocument();
});
it("should display error alert when request has failed", () => {
render(
<LogDetailContent

View file

@ -56,6 +56,7 @@ export interface LogDetailContentProps {
/** When true, log details (messages/response) are still being lazy-loaded. */
isLoadingDetails?: boolean;
accessToken?: string | null;
userEmail?: string;
}
/**
@ -66,7 +67,12 @@ export interface LogDetailContentProps {
* Designed to be placed inside LogDetailsDrawer's right panel so it can
* be reused for both single-log and session-mode views.
*/
export function LogDetailContent({ logEntry, isLoadingDetails = false, accessToken }: LogDetailContentProps) {
export function LogDetailContent({
logEntry,
isLoadingDetails = false,
accessToken,
userEmail,
}: LogDetailContentProps) {
const metadata = logEntry.metadata || {};
const hasError = metadata.status === "failure";
const errorInfo = hasError ? metadata.error_information : null;
@ -142,6 +148,11 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
<DescriptionItem label="Model">{logEntry.model}</DescriptionItem>
<DescriptionItem label="Provider">{logEntry.custom_llm_provider || "-"}</DescriptionItem>
<DescriptionItem label="Call Type">{logEntry.call_type}</DescriptionItem>
{logEntry.user && (
<DescriptionItem label="User">
<UserIdentity userId={logEntry.user} email={userEmail} />
</DescriptionItem>
)}
<DescriptionItem label="Model ID">
<TruncatedValue value={logEntry.model_id} />
</DescriptionItem>
@ -333,6 +344,16 @@ function TagsSection({ tags }: { tags: Record<string, any> }) {
);
}
function UserIdentity({ userId, email }: { userId: string; email?: string }) {
if (!email || email === userId) return <TruncatedValue value={userId} />;
return (
<span className="inline-flex items-center gap-2">
<span>{email}</span>
<TruncatedValue value={userId} />
</span>
);
}
function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) {
const handleClick = () => {
const el = document.getElementById("guardrail-section");

View file

@ -14,8 +14,13 @@ vi.mock("@/app/(dashboard)/hooks/logDetails/useLogDetails", () => ({
useLogDetails: () => ({ data: null, isLoading: false }),
}));
const mockUseUserLookup = vi.fn(() => ({ data: undefined }));
vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
useUserLookup: (userId: string | null) => mockUseUserLookup(userId),
}));
vi.mock("./LogDetailContent", () => ({
LogDetailContent: () => null,
LogDetailContent: ({ userEmail }: { userEmail?: string }) => <span>user-email:{userEmail ?? "none"}</span>,
GuardrailJumpLink: () => null,
}));
@ -124,6 +129,38 @@ describe("LogDetailsDrawer session sidebar sorting", () => {
});
});
describe("LogDetailsDrawer internal user email", () => {
const renderSingleLog = (user: string | undefined) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<LogDetailsDrawer
open
onClose={() => {}}
logEntry={makeLog({ request_id: "single", user })}
accessToken="token"
/>
</QueryClientProvider>,
);
};
it("looks up the log's internal user and hands the resolved email to the detail content", () => {
mockUseUserLookup.mockReturnValue({ data: { user_id: "u-1", user_email: "alice@example.com" } });
renderSingleLog("u-1");
expect(mockUseUserLookup).toHaveBeenCalledWith("u-1");
expect(screen.getByText("user-email:alice@example.com")).toBeInTheDocument();
});
it("skips the lookup and passes no email when the log has no internal user", () => {
mockUseUserLookup.mockReturnValue({ data: undefined });
renderSingleLog(undefined);
expect(mockUseUserLookup).toHaveBeenCalledWith(null);
expect(screen.getByText("user-email:none")).toBeInTheDocument();
});
});
describe("LogDetailsDrawer session sidebar auto-router icon", () => {
const routedSessionLogs = [
makeLog({ request_id: "routed", model: "claude-opus-4-8", model_group: "smart-router" }),

View file

@ -17,6 +17,7 @@ import { getSpendString } from "@/utils/dataUtils";
import { normalizeGuardrailEntries, sortSessionLogs, SessionLogSortMode } from "./utils";
import { DRAWER_WIDTH } from "./constants";
import { useLogDetails } from "@/app/(dashboard)/hooks/logDetails/useLogDetails";
import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers";
export interface LogDetailsDrawerProps {
open: boolean;
@ -245,6 +246,7 @@ export function LogDetailsDrawer({
const logDetails = useLogDetails(currentLog?.request_id, startTime, open && !!currentLog?.request_id);
const detailsData = logDetails.data as any;
const isLoadingDetails = logDetails.isLoading;
const { data: logUser } = useUserLookup(open && currentLog?.user ? currentLog.user : null);
// Build an enriched log entry that merges lazy-loaded details.
// The list endpoint may already include messages/response when store_prompts_in_spend_logs is enabled,
@ -465,6 +467,7 @@ export function LogDetailsDrawer({
logEntry={enrichedLog}
isLoadingDetails={isLoadingDetails}
accessToken={accessToken ?? null}
userEmail={logUser?.user_email || undefined}
/>
</div>
</div>

View file

@ -4,6 +4,7 @@ import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } fr
import { ScrollText } from "lucide-react";
import { useMemo, useState, type ReactNode } from "react";
import { useUserEmailLookup } from "@/app/(dashboard)/hooks/users/useUsers";
import { DataTable, DataTableFilterDrawer, DataTableToolbar } from "@/components/shared/DataTable";
import type { Team } from "../key_team_helpers/key_list";
@ -73,10 +74,13 @@ export function RequestLogsTable({
}: RequestLogsTableProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
const userIds = useMemo(() => data.flatMap((log) => (log.user ? [log.user] : [])), [data]);
const { data: emailByUserId } = useUserEmailLookup(userIds);
const columns = useMemo(() => {
const deps = { onKeyHashClick, onSessionClick };
return getRequestLogsTableColumns(deps);
}, [onKeyHashClick, onSessionClick]);
const resolveUserEmail = (userId: string) => emailByUserId?.[userId];
return getRequestLogsTableColumns({ onKeyHashClick, onSessionClick, resolveUserEmail });
}, [onKeyHashClick, onSessionClick, emailByUserId]);
const isFiltered = columnFilters.length > 0 || searchValue !== "";

View file

@ -75,6 +75,27 @@ describe("Cost column", () => {
});
});
describe("Internal User column", () => {
const emailById: Record<string, string> = { "106514937785257944828": "alice@example.com" };
const deps = { ...noopDeps, resolveUserEmail: (userId: string) => emailById[userId] };
it("shows the user's email instead of the raw id, with both in the tooltip", async () => {
const user = userEvent.setup();
renderRows([logEntry({ request_id: "req-known-user", user: "106514937785257944828" })], deps);
const emailCell = screen.getByText("alice@example.com");
expect(screen.queryByText("106514937785257944828")).not.toBeInTheDocument();
await user.hover(emailCell);
expect(await screen.findByText("alice@example.com (106514937785257944828)")).toBeInTheDocument();
});
it("falls back to the raw id when no email is known for the user", () => {
renderRows([logEntry({ request_id: "req-unknown-user", user: "unknown-user-id" })], deps);
expect(screen.getByText("unknown-user-id")).toBeInTheDocument();
});
});
describe("Tokens column", () => {
const sessionRow: Partial<LogEntry> = {
request_id: "req-session-tokens",

View file

@ -15,6 +15,7 @@ import { AgentBadge, AgentIcon, BatchBadge, LlmBadge, McpBadge, SparkleIcon, Wre
export interface RequestLogsTableColumnsDeps {
onKeyHashClick: (keyHash: string) => void;
onSessionClick: (log: LogEntry) => void;
resolveUserEmail?: (userId: string) => string | undefined;
}
const readMetaString = (metadata: Record<string, unknown> | undefined, key: string): string | undefined => {
@ -32,14 +33,25 @@ const readMcpLogoUrl = (metadata: Record<string, unknown> | undefined): string |
const getLogoUrl = (row: LogEntry, provider: string): string =>
readMcpLogoUrl(row.metadata) ?? (provider ? getProviderLogoAndName(provider).logo : "");
function TruncatedText({ value }: { value: string | undefined }) {
function TruncatedText({ value, tooltip }: { value: string | undefined; tooltip?: string }) {
const display = value ?? "-";
return <CellTooltip content={display} trigger={<span className="max-w-[15ch] truncate block">{display}</span>} />;
return (
<CellTooltip
content={tooltip ?? display}
trigger={<span className="max-w-[15ch] truncate block">{display}</span>}
/>
);
}
function UserCell({ userId, email }: { userId: string | undefined; email: string | undefined }) {
if (!userId || !email || email === userId) return <TruncatedText value={userId} />;
return <TruncatedText value={email} tooltip={`${email} (${userId})`} />;
}
export const getRequestLogsTableColumns = ({
onKeyHashClick,
onSessionClick,
resolveUserEmail = () => undefined,
}: RequestLogsTableColumnsDeps): ColumnDef<LogEntry>[] => [
{
id: "startTime",
@ -313,7 +325,12 @@ export const getRequestLogsTableColumns = ({
header: "Internal User",
size: 150,
enableSorting: false,
cell: ({ row }) => <TruncatedText value={row.original.user} />,
cell: ({ row }) => (
<UserCell
userId={row.original.user}
email={row.original.user ? resolveUserEmail(row.original.user) : undefined}
/>
),
},
{
id: "end_user",

View file

@ -28,6 +28,10 @@ export const isAdminRole = (role: string): boolean => {
return all_admin_roles.includes(role);
};
const rolesAllowedToListUsers: string[] = [...all_admin_roles, "Org Admin"];
export const canListUsers = (role: string | null): boolean => rolesAllowedToListUsers.includes(role ?? "");
export const isProxyAdminRole = (role: string): boolean => {
return role === "proxy_admin" || role === "Admin";
};