From 1eee8ce382d39d32d518688814981f2b7f8b126f Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 8 Aug 2026 20:45:44 +0000 Subject: [PATCH] feat(ui): add an Internal User filter to the request logs page The logs page could filter by end user but never by the users the proxy knows about, so admins had no way to narrow logs to a person by email. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../view_logs/RequestLogsFilters.test.tsx | 68 ++++++++++++++++++- .../view_logs/RequestLogsFilters.tsx | 51 +++++++++++++- .../components/view_logs/RequestLogsPanel.tsx | 4 +- .../components/view_logs/RequestLogsTable.tsx | 12 +++- .../components/view_logs/log_filter_logic.tsx | 1 + 5 files changed, 130 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx index 82b179b2654..c2df8d97bc1 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -18,9 +18,14 @@ vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({ useInfiniteSpendLogEndUsers: vi.fn(), })); +vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ + useInfiniteUsers: vi.fn(), +})); + import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; +import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; const emptyInfiniteQuery = { data: { pages: [], pageParams: [] }, @@ -32,10 +37,16 @@ const emptyInfiniteQuery = { const LOGS_WINDOW = { start_date: "2026-07-23 00:00:00", end_date: "2026-07-24 00:00:00" }; -function renderFilters(filters: Record = {}) { +function renderFilters(filters: Record = {}, canFilterByInternalUser = true) { const set = vi.fn(); renderWithProviders( - filters[id]} set={set} teams={[]} logsWindow={LOGS_WINDOW} />, + filters[id]} + set={set} + teams={[]} + logsWindow={LOGS_WINDOW} + canFilterByInternalUser={canFilterByInternalUser} + />, ); return { set }; } @@ -53,6 +64,7 @@ describe("RequestLogsFilters", () => { vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue( emptyInfiniteQuery as unknown as ReturnType, ); + vi.mocked(useInfiniteUsers).mockReturnValue(emptyInfiniteQuery as unknown as ReturnType); }); it("renders every backend-supported filter field", async () => { @@ -62,6 +74,7 @@ describe("RequestLogsFilters", () => { "Team ID", "Status", "Key Alias", + "Internal User", "End User", "Error Code", "Error Message", @@ -164,8 +177,57 @@ describe("RequestLogsFilters", () => { it("scopes the End User lookup to the window the logs table is showing", async () => { const otherWindow = { start_date: "2026-01-01 00:00:00", end_date: "2026-01-02 00:00:00" }; - renderWithProviders( undefined} set={vi.fn()} teams={[]} logsWindow={otherWindow} />); + renderWithProviders( + undefined} + set={vi.fn()} + teams={[]} + logsWindow={otherWindow} + canFilterByInternalUser + />, + ); await waitFor(() => expect(useInfiniteSpendLogEndUsers).toHaveBeenCalledWith(otherWindow, 50, undefined)); }); + + it("offers internal users by email and filters on their user id", async () => { + vi.mocked(useInfiniteUsers).mockReturnValue({ + ...emptyInfiniteQuery, + data: { + pages: [ + { + users: [{ user_id: "u-1", user_email: "bob@acme.com", user_alias: null }], + page: 1, + page_size: 50, + total: 1, + total_pages: 1, + }, + ], + pageParams: [1], + }, + } as unknown as ReturnType); + const user = userEvent.setup(); + const { set } = renderFilters(); + + await user.click(await screen.findByPlaceholderText("Search a user by email")); + await user.click(await screen.findByText("bob@acme.com")); + + expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.USER_ID, "u-1"); + }); + + it("pushes the Internal User query to the server rather than filtering a preloaded list", async () => { + const user = userEvent.setup(); + renderFilters(); + + await user.type(await screen.findByPlaceholderText("Search a user by email"), "bob"); + + await waitFor(() => expect(useInfiniteUsers).toHaveBeenCalledWith(50, "bob")); + }); + + it("hides the Internal User filter from callers the proxy scopes to their own logs", async () => { + renderFilters({}, false); + + expect(await screen.findByText("End User")).toBeInTheDocument(); + expect(screen.queryByText("Internal User")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx index 2005b868cd6..b5d719a853d 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -5,6 +5,7 @@ import { useMemo, useState } from "react"; import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; +import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { DataTableFilterField } from "@/components/shared/DataTable"; import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; @@ -189,6 +190,49 @@ function EndUserFilterField({ ); } +function InternalUserFilterField({ + value, + onChange, +}: { + value: string; + onChange: (value: string | undefined) => void; +}) { + const [search, setSearch] = useState(""); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteUsers( + PAGE_SIZE, + emptyToUndefined(search), + ); + + const options = useMemo(() => { + const seen = new Set(); + return (data?.pages ?? []).flatMap((page) => + page.users.flatMap((user) => { + if (!user.user_id || seen.has(user.user_id)) return []; + seen.add(user.user_id); + const name = user.user_email || user.user_alias || ""; + return [{ label: name || user.user_id, value: user.user_id, sublabel: name === "" ? undefined : user.user_id }]; + }), + ); + }, [data]); + + return ( + + onChange(emptyToUndefined(next))} + onSearchChange={setSearch} + onLoadMore={() => void fetchNextPage()} + hasNextPage={hasNextPage} + isLoading={isLoading} + isFetchingNextPage={isFetchingNextPage} + placeholder="Search a user by email" + emptyText="No users found" + /> + + ); +} + function ErrorCodeFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) { const [query, setQuery] = useState(""); @@ -243,9 +287,10 @@ interface RequestLogsFiltersProps { set: (columnId: string, value: unknown) => void; teams: Team[]; logsWindow: LogsWindow; + canFilterByInternalUser: boolean; } -export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsFiltersProps) { +export function RequestLogsFilters({ get, set, teams, logsWindow, canFilterByInternalUser }: RequestLogsFiltersProps) { const valueOf = (id: string): string => asString(get(id)); const setter = (id: string) => (next: string | undefined) => set(id, next); @@ -279,6 +324,10 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF teamId={valueOf(LOG_FILTER_IDS.TEAM_ID)} /> + {canFilterByInternalUser && ( + + )} + void; teams: Team[]; logsWindow: LogsWindow; + canFilterByInternalUser: boolean; toolbarChildren?: ReactNode; } @@ -69,6 +70,7 @@ export function RequestLogsTable({ onSessionClick, teams, logsWindow, + canFilterByInternalUser, toolbarChildren, }: RequestLogsTableProps) { const [filtersOpen, setFiltersOpen] = useState(false); @@ -122,7 +124,15 @@ export function RequestLogsTable({ title="Filters" description="Narrow down request logs" > - {({ get, set }) => } + {({ get, set }) => ( + + )} )} diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index 8244066cadb..83b7b56de6c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -42,6 +42,7 @@ export const LOG_FILTER_LABELS: Record = { [LOG_FILTER_IDS.SESSION_ID]: "Session ID", [LOG_FILTER_IDS.MODEL_ID]: "Model", [LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "Public model / search tool", + [LOG_FILTER_IDS.USER_ID]: "Internal User", }; export interface LogsWindow {