From eb385cca3e1acfcab594d23356e07a7bc22141d3 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:27:22 -0700 Subject: [PATCH] fix(ui): restore key activity search to the top of the tab and add model activity search (#44521) * fix(ui): restore key activity search and add model activity search Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore(ui): drop rebuilt dashboard bundle from the usage search change Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(ui): format usage search tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): only show model no-match when the range has models Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Mubashir Osmani Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/UsagePageView.test.tsx | 77 ++++++++++++++++++- .../_components/components/UsagePageView.tsx | 47 +++++++++-- .../KeyActivityPanel.integration.test.tsx | 19 +++++ .../UsagePage/components/KeyActivityPanel.tsx | 18 ++--- .../UsagePage/modelActivityFilter.test.ts | 46 +++++++++++ .../UsagePage/modelActivityFilter.ts | 14 ++++ 6 files changed, 202 insertions(+), 19 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.test.ts create mode 100644 ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index 41873ca554b..d0c5f4c49b4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx @@ -4,14 +4,31 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "@/../tests/test-utils"; +import { processActivityData } from "@/components/activity_metrics"; import type { Organization } from "@/components/networking"; +import type { ModelActivityData } from "@/components/UsagePage/types"; import * as networking from "@/components/networking"; import UsagePage from "./UsagePageView"; +const modelActivity = (label: string): ModelActivityData => ({ + label, + total_requests: 1, + total_successful_requests: 1, + total_failed_requests: 0, + total_cache_read_input_tokens: 0, + total_cache_creation_input_tokens: 0, + total_tokens: 10, + prompt_tokens: 5, + completion_tokens: 5, + total_spend: 0.01, + top_models: [], + daily_data: [], +}); + // Polyfill ResizeObserver for test environment beforeAll(() => { if (typeof window !== "undefined" && !window.ResizeObserver) { @@ -36,10 +53,14 @@ vi.mock("@/components/networking", () => ({ // Mock child components to simplify testing vi.mock("@/components/activity_metrics", () => ({ - ActivityMetrics: ({ modelMetrics }: { modelMetrics?: { __source?: string } }) => ( -
{`activity-source:${modelMetrics?.__source ?? "none"}`}
+ ActivityMetrics: ({ modelMetrics }: { modelMetrics?: Record }) => ( +
+ {Object.entries(modelMetrics ?? {}).map(([model, metrics]) => ( + {metrics.label} + ))} +
), - processActivityData: (_data: unknown, key: string) => ({ __source: key }), + processActivityData: vi.fn(), })); vi.mock("@/components/view_user_spend", () => ({ @@ -366,6 +387,10 @@ describe("UsagePage", () => { }; beforeEach(() => { + vi.mocked(processActivityData).mockReset(); + vi.mocked(processActivityData).mockImplementation((_data, key) => ({ + [key]: modelActivity(`activity-source:${key}`), + })); mockUseAuthorized.mockReturnValue({ isLoading: false, isAuthorized: true, @@ -1239,6 +1264,50 @@ describe("UsagePage", () => { }); expect(screen.queryByText("activity-source:model_groups")).not.toBeInTheDocument(); }); + + it("filters Model Activity by model name and shows an empty state when there are no matches", async () => { + vi.mocked(processActivityData).mockReturnValue({ + "openai/gpt-4o": modelActivity("GPT-4o"), + "anthropic/claude-3": modelActivity("Claude 3 Sonnet"), + }); + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + fireEvent.click(screen.getByRole("tab", { name: "Model Activity" })); + + const modelActivityTab = screen.getByRole("tabpanel", { name: "Model Activity" }); + const searchInput = within(modelActivityTab).getByRole("textbox", { name: "Search models" }); + fireEvent.change(searchInput, { target: { value: "GPT-4o" } }); + + expect(within(modelActivityTab).getByText("GPT-4o")).toBeInTheDocument(); + expect(within(modelActivityTab).queryByText("Claude 3 Sonnet")).not.toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: "missing model" } }); + + expect( + within(modelActivityTab).getByText('No models match "missing model" in this date range'), + ).toBeInTheDocument(); + }); + + it("does not show the no-match state when model data is empty", async () => { + vi.mocked(processActivityData).mockReturnValue({}); + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + fireEvent.click(screen.getByRole("tab", { name: "Model Activity" })); + + const modelActivityTab = screen.getByRole("tabpanel", { name: "Model Activity" }); + const searchInput = within(modelActivityTab).getByRole("textbox", { name: "Search models" }); + fireEvent.change(searchInput, { target: { value: "missing model" } }); + + expect( + within(modelActivityTab).queryByText('No models match "missing model" in this date range'), + ).not.toBeInTheDocument(); + }); }); describe("customer usage banner", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 6f18b2fe1d2..4fc43fdabd5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -6,7 +6,7 @@ * Works at 1m+ spend logs, by querying an aggregate table instead. */ -import { ChevronDown, ChevronRight, Download, Info, Sparkles, X } from "lucide-react"; +import { ChevronDown, ChevronRight, Download, Info, Search, Sparkles, X } from "lucide-react"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -14,6 +14,7 @@ import { BarChart } from "@/components/shared/charts"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { Button } from "@/components/ui/button"; import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Skeleton } from "@/components/ui/skeleton"; @@ -31,6 +32,7 @@ import CloudZeroExportModal from "@/components/cloudzero_export_modal"; import UserDropdown from "@/components/common_components/UserDropdown"; import EntityUsageExportModal from "@/components/EntityUsageExport"; import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel"; +import { filterModelActivity } from "@/components/UsagePage/modelActivityFilter"; import { Team } from "@/components/key_team_helpers/key_list"; import { gatewayDailyActivityCall, Organization, tagListCall } from "@/components/networking"; import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; @@ -108,6 +110,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // For non-admins: always set to their own user ID const [selectedUserId, setSelectedUserId] = useState(isAdmin ? null : userID || null); const [modelViewType, setModelViewType] = useState("groups"); + const [modelQuery, setModelQuery] = useState(""); const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false); const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); const [isAiChatOpen, setIsAiChatOpen] = useState(false); @@ -408,6 +411,8 @@ const UsagePage: React.FC = ({ teams, organizations }) => { () => processActivityData(userSpendData, modelViewType === "groups" ? "model_groups" : "models", teams), [userSpendData, modelViewType, teams], ); + const filteredModelMetrics = useMemo(() => filterModelActivity(modelMetrics, modelQuery), [modelMetrics, modelQuery]); + const trimmedModelQuery = modelQuery.trim(); const mcpServerMetrics = useMemo( () => processActivityData(userSpendData, "mcp_servers", teams), [userSpendData, teams], @@ -865,13 +870,43 @@ const UsagePage: React.FC = ({ teams, organizations }) => { {/* Activity Panel */} -
+
+ + + + + setModelQuery(event.target.value)} + /> + {trimmedModelQuery !== "" && ( + + setModelQuery("")} + > + + + + )} +
- + {trimmedModelQuery !== "" && + Object.keys(modelMetrics).length > 0 && + Object.keys(filteredModelMetrics).length === 0 ? ( +

+ No models match "{trimmedModelQuery}" in this date range +

+ ) : ( + + )} { expect(screen.queryByRole("button", { name: /old-scope-key/ })).not.toBeInTheDocument(); expect(secondScopeFetch).toHaveBeenCalledWith(0, 50); }); + + it("renders key search before the summary metrics", async () => { + render( + , + ); + + const searchInput = await screen.findByRole("textbox", { name: "Search keys" }); + const summaryHeading = await screen.findByRole("heading", { name: "Overall Usage" }); + + expect(searchInput.compareDocumentPosition(summaryHeading) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx index ad28a398c83..77e7a79dc6b 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx @@ -384,15 +384,6 @@ const KeyActivityPanel: React.FC = ({ return (
- {summaryLoading ? ( - - ) : ( - - )}
@@ -418,6 +409,15 @@ const KeyActivityPanel: React.FC = ({ )}
+ {summaryLoading ? ( + + ) : ( + + )} {noMatches ? emptyFilterBody :
{listBody}
} {showSearchErrorNote && (

Could not search all keys. {searchRetryButton}

diff --git a/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.test.ts b/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.test.ts new file mode 100644 index 00000000000..892e3910f65 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { filterModelActivity } from "./modelActivityFilter"; +import type { ModelActivityData } from "./types"; + +const activity = (label: string): ModelActivityData => ({ + label, + total_requests: 1, + total_successful_requests: 1, + total_failed_requests: 0, + total_cache_read_input_tokens: 0, + total_cache_creation_input_tokens: 0, + total_tokens: 10, + prompt_tokens: 5, + completion_tokens: 5, + total_spend: 0.01, + top_models: [], + daily_data: [], +}); + +const modelMetrics: Record = { + "openai/gpt-4o": activity("GPT-4o"), + "anthropic/claude-3": activity("Claude 3 Sonnet"), +}; + +describe("filterModelActivity", () => { + it("matches by model key", () => { + expect(Object.keys(filterModelActivity(modelMetrics, "openai"))).toEqual(["openai/gpt-4o"]); + }); + + it("matches by model label", () => { + expect(Object.keys(filterModelActivity(modelMetrics, "Sonnet"))).toEqual(["anthropic/claude-3"]); + }); + + it("matches case-insensitively", () => { + expect(Object.keys(filterModelActivity(modelMetrics, "GPT-4O"))).toEqual(["openai/gpt-4o"]); + }); + + it("returns all models unchanged for a whitespace-only query", () => { + expect(filterModelActivity(modelMetrics, " ")).toBe(modelMetrics); + }); + + it("returns an empty record when nothing matches", () => { + expect(filterModelActivity(modelMetrics, "unknown")).toEqual({}); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.ts b/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.ts new file mode 100644 index 00000000000..ee85a5fa894 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.ts @@ -0,0 +1,14 @@ +import type { ModelActivityData } from "./types"; + +export function filterModelActivity( + modelMetrics: Record, + query: string, +): Record { + const needle = query.trim().toLowerCase(); + if (needle === "") return modelMetrics; + return Object.fromEntries( + Object.entries(modelMetrics).filter( + ([model, data]) => model.toLowerCase().includes(needle) || data.label.toLowerCase().includes(needle), + ), + ); +}