From 1577b657155d85d7a553ee3901213a5c1c948557 Mon Sep 17 00:00:00 2001 From: Mubashir Osmani Date: Thu, 17 Sep 2026 19:48:12 +0000 Subject: [PATCH] feat(ui): add search bar to usage Model Activity tab Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/EntityUsage/EntityUsage.tsx | 3 +- .../_components/components/UsagePageView.tsx | 3 +- .../components/ActivitySearchPanel.tsx | 70 +++++++++++++++++++ .../UsagePage/components/KeyActivityPanel.tsx | 54 +++----------- .../components/ModelActivityPanel.test.tsx | 70 +++++++++++++++++++ .../components/ModelActivityPanel.tsx | 26 +++++++ .../UsagePage/modelActivityFilter.test.ts | 66 +++++++++++++++++ .../UsagePage/modelActivityFilter.ts | 17 +++++ 8 files changed, 264 insertions(+), 45 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/UsagePage/components/ActivitySearchPanel.tsx create mode 100644 ui/litellm-dashboard/src/components/UsagePage/components/ModelActivityPanel.test.tsx create mode 100644 ui/litellm-dashboard/src/components/UsagePage/components/ModelActivityPanel.tsx 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/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 6687bd4df03..867eaa59c89 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -44,6 +44,7 @@ import EndpointUsage from "../EndpointUsage/EndpointUsage"; import ModelViewToggle, { ModelViewType } from "../ModelViewToggle"; import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView"; import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel"; +import ModelActivityPanel from "@/components/UsagePage/components/ModelActivityPanel"; import TopModelView from "./TopModelView"; import TeamUserSpendCard from "./TeamUserSpendCard"; @@ -649,7 +650,7 @@ const EntityUsage: React.FC = ({
- + ), }, 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 691c5dc839a..fe305ae36ae 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 @@ -32,6 +32,7 @@ import UserDropdown from "@/components/common_components/UserDropdown"; import EntityUsageExportModal from "@/components/EntityUsageExport"; import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason"; import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel"; +import ModelActivityPanel from "@/components/UsagePage/components/ModelActivityPanel"; import { Team } from "@/components/key_team_helpers/key_list"; import { gatewayDailyActivityCall, @@ -901,7 +902,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
- + diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/ActivitySearchPanel.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/ActivitySearchPanel.tsx new file mode 100644 index 00000000000..dd1ce56337f --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/ActivitySearchPanel.tsx @@ -0,0 +1,70 @@ +import { Search, X } from "lucide-react"; +import React, { useMemo, useState } from "react"; + +import { ActivityMetrics } from "@/components/activity_metrics"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; + +import type { ModelActivityData } from "../types"; + +interface ActivitySearchPanelProps { + metrics: Record; + filter: (metrics: Record, query: string) => Record; + noun: string; + placeholder: string; + searchLabel: string; + clearLabel: string; + hidePromptCachingMetrics?: boolean; +} + +const ActivitySearchPanel: React.FC = ({ + metrics, + filter, + noun, + placeholder, + searchLabel, + clearLabel, + hidePromptCachingMetrics = false, +}) => { + const [query, setQuery] = useState(""); + const filtered = useMemo(() => filter(metrics, query), [metrics, filter, query]); + const totalKeys = Object.keys(metrics).length; + const shownKeys = Object.keys(filtered).length; + const isFiltering = query.trim() !== ""; + + return ( +
+
+ + + + + setQuery(e.target.value)} + /> + {isFiltering && ( + + setQuery("")}> + + + + )} + + + Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} {noun} + +
+ {isFiltering && totalKeys > 0 && shownKeys === 0 ? ( +

+ No {noun} match "{query.trim()}" in this date range +

+ ) : ( + + )} +
+ ); +}; + +export default ActivitySearchPanel; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx index 8287a04d0c7..d6865f4d2df 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx @@ -1,11 +1,8 @@ -import { Search, X } from "lucide-react"; -import React, { useMemo, useState } from "react"; - -import { ActivityMetrics } from "@/components/activity_metrics"; -import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; +import React from "react"; import { filterKeyActivity } from "../keyActivityFilter"; import type { ModelActivityData } from "../types"; +import ActivitySearchPanel from "./ActivitySearchPanel"; interface KeyActivityPanelProps { keyMetrics: Record; @@ -13,45 +10,16 @@ interface KeyActivityPanelProps { } const KeyActivityPanel: React.FC = ({ keyMetrics, hidePromptCachingMetrics = false }) => { - const [query, setQuery] = useState(""); - const filtered = useMemo(() => filterKeyActivity(keyMetrics, query), [keyMetrics, query]); - const totalKeys = Object.keys(keyMetrics).length; - const shownKeys = Object.keys(filtered).length; - const isFiltering = query.trim() !== ""; - return ( -
-
- - - - - setQuery(e.target.value)} - /> - {isFiltering && ( - - setQuery("")}> - - - - )} - - - Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys - -
- {isFiltering && totalKeys > 0 && shownKeys === 0 ? ( -

- No keys match "{query.trim()}" in this date range -

- ) : ( - - )} -
+ ); }; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/ModelActivityPanel.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/ModelActivityPanel.test.tsx new file mode 100644 index 00000000000..260aaa9dd10 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/ModelActivityPanel.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ModelActivityData } from "../types"; +import ModelActivityPanel from "./ModelActivityPanel"; + +vi.mock("@/components/activity_metrics", () => ({ + ActivityMetrics: ({ modelMetrics }: { modelMetrics: Record }) => ( +
    + {Object.keys(modelMetrics).map((model) => ( +
  • {model}
  • + ))} +
+ ), +})); + +function activity(label: string): ModelActivityData { + return { + 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_api_keys: [], + top_models: [], + daily_data: [], + }; +} + +const modelMetrics: Record = { + "gpt-5.5": activity("GPT-5.5"), + "claude-sonnet-4": activity("Claude Sonnet"), +}; + +describe("ModelActivityPanel", () => { + it("renders every model and the full count before searching", () => { + render(); + expect(screen.getByTestId("rendered-models")).toHaveTextContent("gpt-5.5claude-sonnet-4"); + expect(screen.getByText("Showing 2 of 2 models")).toBeInTheDocument(); + }); + + it("narrows the rendered models to those matching the query", () => { + render(); + fireEvent.change(screen.getByLabelText("Search models"), { target: { value: "sonnet" } }); + expect(screen.getByTestId("rendered-models")).toHaveTextContent("claude-sonnet-4"); + expect(screen.getByTestId("rendered-models")).not.toHaveTextContent("gpt-5.5"); + expect(screen.getByText("Showing 1 of 2 models")).toBeInTheDocument(); + }); + + it("shows an empty state instead of zeroed metrics when nothing matches", () => { + render(); + fireEvent.change(screen.getByLabelText("Search models"), { target: { value: "llama" } }); + expect(screen.queryByTestId("rendered-models")).not.toBeInTheDocument(); + expect(screen.getByText('No models match "llama" in this date range')).toBeInTheDocument(); + }); + + it("clears the search and restores every model", () => { + render(); + fireEvent.change(screen.getByLabelText("Search models"), { target: { value: "gpt" } }); + expect(screen.getByTestId("rendered-models")).toHaveTextContent("gpt-5.5"); + fireEvent.click(screen.getByLabelText("Clear model search")); + expect(screen.getByLabelText("Search models")).toHaveValue(""); + expect(screen.getByTestId("rendered-models")).toHaveTextContent("gpt-5.5claude-sonnet-4"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/ModelActivityPanel.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/ModelActivityPanel.tsx new file mode 100644 index 00000000000..99b6a824b7d --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/ModelActivityPanel.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +import { filterModelActivity } from "../modelActivityFilter"; +import type { ModelActivityData } from "../types"; +import ActivitySearchPanel from "./ActivitySearchPanel"; + +interface ModelActivityPanelProps { + modelMetrics: Record; + hidePromptCachingMetrics?: boolean; +} + +const ModelActivityPanel: React.FC = ({ modelMetrics, hidePromptCachingMetrics = false }) => { + return ( + + ); +}; + +export default ModelActivityPanel; 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..4045a1f02b6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { filterModelActivity, modelActivityMatches } from "./modelActivityFilter"; +import type { ModelActivityData } from "./types"; + +function activity(label: string): ModelActivityData { + return { + 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_api_keys: [], + top_models: [], + daily_data: [], + }; +} + +const gpt = activity("GPT-5.5"); +const claude = activity("Claude Sonnet"); + +const modelMetrics: Record = { + "gpt-5.5": gpt, + "claude-sonnet-4": claude, +}; + +describe("modelActivityMatches", () => { + it("matches every model on an empty or whitespace query", () => { + expect(modelActivityMatches("gpt-5.5", gpt, "")).toBe(true); + expect(modelActivityMatches("gpt-5.5", gpt, " ")).toBe(true); + }); + + it("matches the model key case-insensitively", () => { + expect(modelActivityMatches("gpt-5.5", gpt, "GPT-5")).toBe(true); + expect(modelActivityMatches("claude-sonnet-4", claude, "gpt-5")).toBe(false); + }); + + it("matches the model label case-insensitively", () => { + expect(modelActivityMatches("claude-sonnet-4", claude, "sonnet")).toBe(true); + expect(modelActivityMatches("gpt-5.5", gpt, "sonnet")).toBe(false); + }); + + it("trims surrounding whitespace from the query", () => { + expect(modelActivityMatches("gpt-5.5", gpt, " gpt-5.5 ")).toBe(true); + }); +}); + +describe("filterModelActivity", () => { + it("returns the same object when the query is blank", () => { + expect(filterModelActivity(modelMetrics, "")).toBe(modelMetrics); + }); + + it("keeps only the models matching the query, preserving their keys", () => { + expect(filterModelActivity(modelMetrics, "sonnet")).toEqual({ "claude-sonnet-4": claude }); + expect(Object.keys(filterModelActivity(modelMetrics, "gpt"))).toEqual(["gpt-5.5"]); + }); + + it("returns an empty record when nothing matches", () => { + expect(filterModelActivity(modelMetrics, "llama")).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..aa26b984ea3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.ts @@ -0,0 +1,17 @@ +import type { ModelActivityData } from "./types"; + +export function modelActivityMatches(modelKey: string, data: ModelActivityData, query: string): boolean { + const needle = query.trim().toLowerCase(); + if (needle === "") return true; + return [modelKey, data.label].some((field) => field?.toLowerCase().includes(needle) ?? false); +} + +export function filterModelActivity( + modelMetrics: Record, + query: string, +): Record { + if (query.trim() === "") return modelMetrics; + return Object.fromEntries( + Object.entries(modelMetrics).filter(([modelKey, data]) => modelActivityMatches(modelKey, data, query)), + ); +}