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 <mubashir@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-06 09:27:22 -07:00 • committed by GitHub
parent bd23e6fc3d
commit eb385cca3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 202 additions and 19 deletions

View file

@ -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 } }) => (
<div>{`activity-source:${modelMetrics?.__source ?? "none"}`}</div>
ActivityMetrics: ({ modelMetrics }: { modelMetrics?: Record<string, { label: string }> }) => (
<div>
{Object.entries(modelMetrics ?? {}).map(([model, metrics]) => (
<span key={model}>{metrics.label}</span>
))}
</div>
),
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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", () => {

View file

@ -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<UsagePageProps> = ({ teams, organizations }) => {
// For non-admins: always set to their own user ID
const [selectedUserId, setSelectedUserId] = useState<string | null>(isAdmin ? null : userID || null);
const [modelViewType, setModelViewType] = useState<ModelViewType>("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<UsagePageProps> = ({ 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<UsagePageProps> = ({ teams, organizations }) => {
{/* Activity Panel */}
<TabsContent value="models" keepMounted>
<div className="flex justify-end mt-2 mb-4">
<div className="mt-2 mb-4 flex flex-wrap items-center justify-between gap-3">
<InputGroup className="max-w-md">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
aria-label="Search models"
placeholder="Search by model name"
value={modelQuery}
onChange={(event) => setModelQuery(event.target.value)}
/>
{trimmedModelQuery !== "" && (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label="Clear model search"
onClick={() => setModelQuery("")}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
</div>
<ActivityMetrics
modelMetrics={modelMetrics}
fetchTopApiKeys={dailyActivityRequest ? fetchTopApiKeys : undefined}
/>
{trimmedModelQuery !== "" &&
Object.keys(modelMetrics).length > 0 &&
Object.keys(filteredModelMetrics).length === 0 ? (
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
No models match &quot;{trimmedModelQuery}&quot; in this date range
</p>
) : (
<ActivityMetrics
modelMetrics={filteredModelMetrics}
fetchTopApiKeys={dailyActivityRequest ? fetchTopApiKeys : undefined}
/>
)}
</TabsContent>
<TabsContent value="keys" keepMounted>
<KeyActivityPanel

View file

@ -671,4 +671,23 @@ describe("KeyActivityPanel", () => {
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(
<KeyActivityPanel
summary={summary}
fetchKeyPage={vi.fn().mockResolvedValue(pageResponse([], 0))}
fetchKeyDetail={vi.fn()}
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
teams={[]}
/>,
);
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,
);
});
});

View file

@ -384,15 +384,6 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
return (
<div className="space-y-4">
{summaryLoading ? (
<ChartLoader />
) : (
<ActivityMetrics
modelMetrics={{}}
summaryMetrics={summary}
hidePromptCachingMetrics={hidePromptCachingMetrics}
/>
)}
<div className="mt-2 flex flex-wrap items-center gap-3">
<InputGroup className="max-w-md">
<InputGroupAddon>
@ -418,6 +409,15 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
</span>
)}
</div>
{summaryLoading ? (
<ChartLoader />
) : (
<ActivityMetrics
modelMetrics={{}}
summaryMetrics={summary}
hidePromptCachingMetrics={hidePromptCachingMetrics}
/>
)}
{noMatches ? emptyFilterBody : <div className="rounded-lg border">{listBody}</div>}
{showSearchErrorNote && (
<p className="text-sm text-muted-foreground">Could not search all keys. {searchRetryButton}</p>

View file

@ -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<string, ModelActivityData> = {
"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({});
});
});

View file

@ -0,0 +1,14 @@
import type { ModelActivityData } from "./types";
export function filterModelActivity(
modelMetrics: Record<string, ModelActivityData>,
query: string,
): Record<string, ModelActivityData> {
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),
),
);
}