feat(ui): add search bar to usage Model Activity tab

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Mubashir Osmani 2026-09-17 19:48:12 +00:00
parent dbc6c1cfaa
commit 1577b65715
8 changed files with 264 additions and 45 deletions

View file

@ -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<EntityUsageProps> = ({
<div className="flex justify-end mt-2 mb-4">
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
</div>
<ActivityMetrics modelMetrics={modelMetrics} hidePromptCachingMetrics={entityType === "agent"} />
<ModelActivityPanel modelMetrics={modelMetrics} hidePromptCachingMetrics={entityType === "agent"} />
</>
),
},

View file

@ -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<UsagePageProps> = ({ teams, organizations }) => {
<div className="flex justify-end mt-2 mb-4">
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
</div>
<ActivityMetrics modelMetrics={modelMetrics} />
<ModelActivityPanel modelMetrics={modelMetrics} />
</TabsContent>
<TabsContent value="keys" keepMounted>
<KeyActivityPanel keyMetrics={keyMetrics} />

View file

@ -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<string, ModelActivityData>;
filter: (metrics: Record<string, ModelActivityData>, query: string) => Record<string, ModelActivityData>;
noun: string;
placeholder: string;
searchLabel: string;
clearLabel: string;
hidePromptCachingMetrics?: boolean;
}
const ActivitySearchPanel: React.FC<ActivitySearchPanelProps> = ({
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 (
<div className="space-y-4">
<div className="mt-2 flex items-center gap-3">
<InputGroup className="max-w-md">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
aria-label={searchLabel}
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{isFiltering && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label={clearLabel} onClick={() => setQuery("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<span className="text-sm text-muted-foreground">
Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} {noun}
</span>
</div>
{isFiltering && totalKeys > 0 && shownKeys === 0 ? (
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
No {noun} match &quot;{query.trim()}&quot; in this date range
</p>
) : (
<ActivityMetrics modelMetrics={filtered} hidePromptCachingMetrics={hidePromptCachingMetrics} />
)}
</div>
);
};
export default ActivitySearchPanel;

View file

@ -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<string, ModelActivityData>;
@ -13,45 +10,16 @@ interface KeyActivityPanelProps {
}
const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({ 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 (
<div className="space-y-4">
<div className="mt-2 flex items-center gap-3">
<InputGroup className="max-w-md">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
aria-label="Search keys"
placeholder="Search by key alias, key hash, user ID, or email"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{isFiltering && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear key search" onClick={() => setQuery("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<span className="text-sm text-muted-foreground">
Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys
</span>
</div>
{isFiltering && totalKeys > 0 && shownKeys === 0 ? (
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
No keys match &quot;{query.trim()}&quot; in this date range
</p>
) : (
<ActivityMetrics modelMetrics={filtered} hidePromptCachingMetrics={hidePromptCachingMetrics} />
)}
</div>
<ActivitySearchPanel
metrics={keyMetrics}
filter={filterKeyActivity}
noun="keys"
placeholder="Search by key alias, key hash, user ID, or email"
searchLabel="Search keys"
clearLabel="Clear key search"
hidePromptCachingMetrics={hidePromptCachingMetrics}
/>
);
};

View file

@ -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<string, ModelActivityData> }) => (
<ul data-testid="rendered-models">
{Object.keys(modelMetrics).map((model) => (
<li key={model}>{model}</li>
))}
</ul>
),
}));
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<string, ModelActivityData> = {
"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(<ModelActivityPanel modelMetrics={modelMetrics} />);
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(<ModelActivityPanel modelMetrics={modelMetrics} />);
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(<ModelActivityPanel modelMetrics={modelMetrics} />);
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(<ModelActivityPanel modelMetrics={modelMetrics} />);
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");
});
});

View file

@ -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<string, ModelActivityData>;
hidePromptCachingMetrics?: boolean;
}
const ModelActivityPanel: React.FC<ModelActivityPanelProps> = ({ modelMetrics, hidePromptCachingMetrics = false }) => {
return (
<ActivitySearchPanel
metrics={modelMetrics}
filter={filterModelActivity}
noun="models"
placeholder="Search by model name"
searchLabel="Search models"
clearLabel="Clear model search"
hidePromptCachingMetrics={hidePromptCachingMetrics}
/>
);
};
export default ModelActivityPanel;

View file

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

View file

@ -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<string, ModelActivityData>,
query: string,
): Record<string, ModelActivityData> {
if (query.trim() === "") return modelMetrics;
return Object.fromEntries(
Object.entries(modelMetrics).filter(([modelKey, data]) => modelActivityMatches(modelKey, data, query)),
);
}