mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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>
This commit is contained in:
parent
97a59c8c90
commit
1eee8ce382
5 changed files with 130 additions and 6 deletions
|
|
@ -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<string, string> = {}) {
|
||||
function renderFilters(filters: Record<string, string> = {}, canFilterByInternalUser = true) {
|
||||
const set = vi.fn();
|
||||
renderWithProviders(
|
||||
<RequestLogsFilters get={(id: string) => filters[id]} set={set} teams={[]} logsWindow={LOGS_WINDOW} />,
|
||||
<RequestLogsFilters
|
||||
get={(id: string) => 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<typeof useInfiniteSpendLogEndUsers>,
|
||||
);
|
||||
vi.mocked(useInfiniteUsers).mockReturnValue(emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteUsers>);
|
||||
});
|
||||
|
||||
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(<RequestLogsFilters get={() => undefined} set={vi.fn()} teams={[]} logsWindow={otherWindow} />);
|
||||
renderWithProviders(
|
||||
<RequestLogsFilters
|
||||
get={() => 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<typeof useInfiniteUsers>);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<SearchSelectOption[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
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 (
|
||||
<DataTableFilterField label="Internal User">
|
||||
<PaginatedSearchSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={(next) => onChange(emptyToUndefined(next))}
|
||||
onSearchChange={setSearch}
|
||||
onLoadMore={() => void fetchNextPage()}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoading={isLoading}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
placeholder="Search a user by email"
|
||||
emptyText="No users found"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
);
|
||||
}
|
||||
|
||||
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 && (
|
||||
<InternalUserFilterField value={valueOf(LOG_FILTER_IDS.USER_ID)} onChange={setter(LOG_FILTER_IDS.USER_ID)} />
|
||||
)}
|
||||
|
||||
<EndUserFilterField
|
||||
value={valueOf(LOG_FILTER_IDS.END_USER)}
|
||||
onChange={setter(LOG_FILTER_IDS.END_USER)}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import moment from "moment";
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells";
|
||||
import { internalUserRoles } from "../../utils/roles";
|
||||
import { all_admin_roles, internalUserRoles } from "../../utils/roles";
|
||||
import type { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import { keyInfoV1Call, uiSpendLogsCall } from "../networking";
|
||||
import KeyInfoView from "../templates/key_info_view";
|
||||
|
|
@ -74,6 +74,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
}, [isLiveTail]);
|
||||
|
||||
const filterByCurrentUser = internalUserRoles.includes(userRole);
|
||||
const canFilterByInternalUser = all_admin_roles.includes(userRole);
|
||||
|
||||
const { logsQuery, filteredLogs, allTeams } = useLogFilterLogic({
|
||||
accessToken,
|
||||
|
|
@ -305,6 +306,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
onSessionClick={handleSessionClick}
|
||||
teams={allTeams ?? []}
|
||||
logsWindow={logsWindow}
|
||||
canFilterByInternalUser={canFilterByInternalUser}
|
||||
toolbarChildren={
|
||||
<LogsTableToolbar
|
||||
startTime={startTime}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ interface RequestLogsTableProps {
|
|||
onSessionClick: (sessionId: string) => 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 }) => <RequestLogsFilters get={get} set={set} teams={teams} logsWindow={logsWindow} />}
|
||||
{({ get, set }) => (
|
||||
<RequestLogsFilters
|
||||
get={get}
|
||||
set={set}
|
||||
teams={teams}
|
||||
logsWindow={logsWindow}
|
||||
canFilterByInternalUser={canFilterByInternalUser}
|
||||
/>
|
||||
)}
|
||||
</DataTableFilterDrawer>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export const LOG_FILTER_LABELS: Record<string, string> = {
|
|||
[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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue