feat(ui): add user filtering to usage page (#22059)

* feat(ui): add user filtering to usage page

Adds "User Usage" as a new view option in the usage page dropdown,
allowing admins to view and filter usage data by individual users
via the existing /user/daily/activity backend endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(ui/): working usage filtering

* fix(ui): use single-select for user filter and add tests

The user entity type's backend endpoint only accepts a single user_id,
so the filter now uses single-select mode instead of multi-select.
Added tests for the new user entity type in EntityUsage and
UsageViewSelect. Updated CLAUDE.md and AGENTS.md with guidance on
UI/backend contract consistency and test coverage for new entity types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* revert: remove unintended package-lock.json changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* revert: restore package-lock.json to merge base state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Krish Dholakia 2026-02-25 10:37:45 -08:00 committed by Sameer Kankute
parent 81377f6679
commit 10f0f7b8c3
10 changed files with 102 additions and 42 deletions

View file

@ -174,6 +174,8 @@ When opening issues or pull requests, follow these templates:
3. **Rate Limits**: Respect provider rate limits in tests
4. **Memory Usage**: Be mindful of memory usage in streaming scenarios
5. **Dependencies**: Keep dependencies minimal and well-justified
6. **UI/Backend Contract Mismatch**: When adding a new entity type to the UI, always check whether the backend endpoint accepts a single value or an array. Match the UI control accordingly (single-select vs. multi-select) to avoid silently dropping user selections
7. **Missing Tests for New Entity Types**: When adding a new entity type (e.g., in `EntityUsage`, `UsageViewSelect`), always add corresponding tests in the existing test files and update any icon/component mocks
## HELPFUL RESOURCES

View file

@ -97,6 +97,10 @@ LiteLLM is a unified interface for 100+ LLM providers with two main components:
- Integration tests for each provider in `tests/llm_translation/`
- Proxy tests in `tests/proxy_unit_tests/`
- Load tests in `tests/load_tests/`
- **Always add tests when adding new entity types or features** — if the existing test file covers other entity types, add corresponding tests for the new one
### UI / Backend Consistency
- When wiring a new UI entity type to an existing backend endpoint, verify the backend API contract (single value vs. array, required vs. optional params) and ensure the UI controls match — e.g., use a single-select dropdown when the backend accepts a single value, not a multi-select
### Database Migrations
- Prisma handles schema migrations

View file

@ -17,6 +17,7 @@ interface UsageExportHeaderProps {
selectedFilters?: string[];
onFiltersChange?: (filters: string[]) => void;
filterOptions?: Array<{ label: string; value: string }>;
filterMode?: "multiple" | "single";
customTitle?: string;
compactLayout?: boolean;
teams?: Team[];
@ -32,6 +33,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
selectedFilters = [],
onFiltersChange,
filterOptions = [],
filterMode = "multiple",
customTitle,
compactLayout = false,
teams = [],
@ -59,11 +61,17 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
<div>
{filterLabel && <Text className="mb-2">{filterLabel}</Text>}
<Select
mode="multiple"
mode={filterMode === "single" ? undefined : "multiple"}
style={{ width: "100%" }}
placeholder={filterPlaceholder}
value={selectedFilters}
onChange={onFiltersChange}
value={filterMode === "single" ? (selectedFilters[0] ?? undefined) : selectedFilters}
onChange={(value: any) => {
if (filterMode === "single") {
onFiltersChange?.(value ? [value] : []);
} else {
onFiltersChange?.(value);
}
}}
options={filterOptions}
allowClear
/>

View file

@ -3,7 +3,7 @@ import type { Team } from "@/components/key_team_helpers/key_list";
export type ExportFormat = "csv" | "json";
export type ExportScope = "daily" | "daily_with_keys" | "daily_with_models";
export type EntityType = "tag" | "team" | "organization" | "customer" | "agent";
export type EntityType = "tag" | "team" | "organization" | "customer" | "agent" | "user";
export interface EntitySpendData {
results: any[];

View file

@ -20,6 +20,7 @@ vi.mock("../../../networking", () => ({
organizationDailyActivityCall: vi.fn(),
customerDailyActivityCall: vi.fn(),
agentDailyActivityCall: vi.fn(),
userDailyActivityCall: vi.fn(),
}));
// Mock the child components to simplify testing
@ -58,6 +59,7 @@ describe("EntityUsage", () => {
const mockOrganizationDailyActivityCall = vi.mocked(networking.organizationDailyActivityCall);
const mockCustomerDailyActivityCall = vi.mocked(networking.customerDailyActivityCall);
const mockAgentDailyActivityCall = vi.mocked(networking.agentDailyActivityCall);
const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall);
const mockSpendData = {
results: [
@ -146,11 +148,13 @@ describe("EntityUsage", () => {
mockOrganizationDailyActivityCall.mockClear();
mockCustomerDailyActivityCall.mockClear();
mockAgentDailyActivityCall.mockClear();
mockUserDailyActivityCall.mockClear();
mockTagDailyActivityCall.mockResolvedValue(mockSpendData);
mockTeamDailyActivityCall.mockResolvedValue(mockSpendData);
mockOrganizationDailyActivityCall.mockResolvedValue(mockSpendData);
mockCustomerDailyActivityCall.mockResolvedValue(mockSpendData);
mockAgentDailyActivityCall.mockResolvedValue(mockSpendData);
mockUserDailyActivityCall.mockResolvedValue(mockSpendData);
});
it("should render with tag entity type and display spend metrics", async () => {
@ -232,6 +236,21 @@ describe("EntityUsage", () => {
});
});
it("should render with user entity type and call user API", async () => {
render(<EntityUsage {...defaultProps} entityType="user" />);
await waitFor(() => {
expect(mockUserDailyActivityCall).toHaveBeenCalled();
});
expect(screen.getByText("User Spend Overview")).toBeInTheDocument();
await waitFor(() => {
const spendElements = screen.getAllByText("$100.50");
expect(spendElements.length).toBeGreaterThan(0);
});
});
it("should switch between tabs", async () => {
render(<EntityUsage {...defaultProps} />);

View file

@ -32,6 +32,7 @@ import {
organizationDailyActivityCall,
tagDailyActivityCall,
teamDailyActivityCall,
userDailyActivityCall,
} from "../../../networking";
import { getProviderLogoAndName } from "../../../provider_info_helpers";
import { BreakdownMetrics, DailyData, EntityMetricWithMetadata, KeyMetricWithMetadata, TagUsage } from "../../types";
@ -156,6 +157,15 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
selectedTags.length > 0 ? selectedTags : null,
);
setSpendData(data);
} else if (entityType === "user") {
const data = await userDailyActivityCall(
accessToken,
startTime,
endTime,
1,
selectedTags.length > 0 ? selectedTags[0] : null,
);
setSpendData(data);
} else {
throw new Error("Invalid entity type");
}
@ -391,6 +401,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
selectedFilters={selectedTags}
onFiltersChange={setSelectedTags}
filterOptions={getAllTags() || undefined}
filterMode={entityType === "user" ? "single" : "multiple"}
teams={teams || []}
/>
<TabGroup>

View file

@ -6,7 +6,7 @@
* Works at 1m+ spend logs, by querying an aggregate table instead.
*/
import { InfoCircleOutlined, LoadingOutlined, UserOutlined } from "@ant-design/icons";
import { InfoCircleOutlined, LoadingOutlined } from "@ant-design/icons";
import {
BarChart,
Card,
@ -498,6 +498,36 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
{/* Your Usage Panel */}
{usageView === "global" && (
<>
{isAdmin && (
<div className="mb-4">
<Text className="mb-2">Filter by user</Text>
<Select
showSearch
allowClear
style={{ width: "100%" }}
placeholder="Select user to filter..."
value={selectedUserId}
onChange={(value) => setSelectedUserId(value ?? null)}
filterOption={false}
onSearch={handleUserSearchChange}
searchValue={userSearchInput}
onPopupScroll={handleUserPopupScroll}
loading={isLoadingUsers}
notFoundContent={isLoadingUsers ? <LoadingOutlined spin /> : "No users found"}
options={userOptions}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextUsersPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
/>
</div>
)}
<TabGroup>
<div className="flex justify-between items-center">
<TabList variant="solid" className="mt-1">
@ -560,41 +590,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
</>
)}
</Text>
{isAdmin && (
<div className="flex items-center gap-2">
<UserOutlined style={{ fontSize: "14px", color: "#6b7280" }} />
<Select
showSearch
allowClear
style={{ width: 300 }}
placeholder="All Users (Global View)"
value={selectedUserId}
onChange={(value) => setSelectedUserId(value ?? null)}
filterOption={false}
onSearch={handleUserSearchChange}
searchValue={userSearchInput}
onPopupScroll={handleUserPopupScroll}
loading={isLoadingUsers}
notFoundContent={isLoadingUsers ? <LoadingOutlined spin /> : "No users found"}
options={userOptions}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextUsersPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
/>
{selectedUserId && (
<span className="text-xs text-gray-500">
Filtering by user
</span>
)}
</div>
)}
</div>
<ViewUserSpend
@ -912,6 +907,18 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
dateValue={dateValue}
/>
)}
{/* User Usage Panel */}
{usageView === "user" && (
<EntityUsage
accessToken={accessToken}
entityType="user"
userID={userID}
userRole={userRole}
entityList={userOptions.length > 0 ? userOptions : null}
premiumUser={premiumUser}
dateValue={dateValue}
/>
)}
{/* User Agent Activity Panel */}
{usageView === "user-agent-activity" && (
<UserAgentActivity accessToken={accessToken} userRole={userRole} dateValue={dateValue} />

View file

@ -79,6 +79,7 @@ vi.mock("@ant-design/icons", async () => {
ShoppingCartOutlined: Icon,
TagsOutlined: Icon,
RobotOutlined: Icon,
UserOutlined: Icon,
LineChartOutlined: Icon,
BarChartOutlined: Icon,
};

View file

@ -7,10 +7,11 @@ import {
ShoppingCartOutlined,
TagsOutlined,
TeamOutlined,
UserOutlined,
} from "@ant-design/icons";
import { Badge, Select } from "antd";
import React from "react";
export type UsageOption = "global" | "organization" | "team" | "customer" | "tag" | "agent" | "user-agent-activity";
export type UsageOption = "global" | "organization" | "team" | "customer" | "tag" | "agent" | "user" | "user-agent-activity";
export interface UsageViewSelectProps {
value: UsageOption;
onChange: (value: UsageOption) => void;
@ -79,6 +80,13 @@ const OPTIONS: OptionConfig[] = [
icon: <RobotOutlined style={{ fontSize: "16px" }} />,
adminOnly: true,
},
{
value: "user",
label: "User Usage",
description: "View usage by individual users",
icon: <UserOutlined style={{ fontSize: "16px" }} />,
adminOnly: true,
},
{
value: "user-agent-activity",
label: "User Agent Activity",

View file

@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{