feat(ui): search Key Activity by key alias, key hash, user id, or email

Team Usage and the main Usage page render every key in the selected
scope with no way to narrow the list. Add a client-side search box
above Key Activity that filters the loaded keys by alias, hash, user
id, or user email, and expose user_id on the daily activity key
metadata so the id is searchable

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ryan 2026-09-11 01:30:00 +00:00
parent 2f46425732
commit a0b55fe68d
13 changed files with 273 additions and 2 deletions

View file

@ -127,6 +127,7 @@ def _key_metadata(api_key_metadata: Mapping[str, _KeyMetadataDict], api_key: str
return KeyMetadata(
key_alias=meta.get("key_alias"),
team_id=meta.get("team_id"),
user_id=meta.get("user_id"),
user_email=meta.get("user_email"),
)

View file

@ -43,6 +43,7 @@ class KeyMetadata(BaseModel):
key_alias: str | None = None
team_id: str | None = None
user_id: str | None = None
user_email: str | None = None

View file

@ -613,6 +613,7 @@ def test_key_metadata_includes_recovered_user_email():
"dirty-key": {
"key_alias": "batch-worker",
"team_id": "team-1",
"user_id": "alice",
"user_email": "alice@example.com",
}
},
@ -620,6 +621,7 @@ def test_key_metadata_includes_recovered_user_email():
)
assert meta.key_alias == "batch-worker"
assert meta.user_id == "alice"
assert meta.user_email == "alice@example.com"
@ -848,9 +850,11 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
mock_deleted_key.token = "deleted-key-hash"
mock_deleted_key.key_alias = "toto-test-2"
mock_deleted_key.team_id = "69cd4b77-b095-4489-8c46-4f2f31d840a2"
mock_deleted_key.user_id = "deleted-key-owner"
mock_prisma.db.litellm_deletedverificationtoken = MagicMock()
mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[mock_deleted_key])
mock_prisma.db.litellm_usertable.find_many = AsyncMock(return_value=[])
result = await get_daily_activity_aggregated(
prisma_client=mock_prisma,
@ -871,6 +875,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
key_data = chat_endpoint.api_key_breakdown["deleted-key-hash"]
assert key_data.metadata.key_alias == "toto-test-2"
assert key_data.metadata.team_id == "69cd4b77-b095-4489-8c46-4f2f31d840a2"
assert key_data.metadata.user_id == "deleted-key-owner"
assert key_data.metrics.spend == 10.0

View file

@ -42,6 +42,7 @@ import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatte
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 TopModelView from "./TopModelView";
import TeamUserSpendCard from "./TeamUserSpendCard";
@ -654,7 +655,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
{
key: "keys",
label: "Key Activity",
content: <ActivityMetrics modelMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />,
content: <KeyActivityPanel keyMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />,
},
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
];

View file

@ -30,6 +30,7 @@ import { ActivityMetrics, processActivityData } from "@/components/activity_metr
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 { Team } from "@/components/key_team_helpers/key_list";
import {
gatewayDailyActivityCall,
@ -886,7 +887,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<ActivityMetrics modelMetrics={modelMetrics} />
</TabsContent>
<TabsContent value="keys" keepMounted>
<ActivityMetrics modelMetrics={keyMetrics} />
<KeyActivityPanel keyMetrics={keyMetrics} />
</TabsContent>
<TabsContent value="mcp" keepMounted>
<ActivityMetrics modelMetrics={mcpServerMetrics} />

View file

@ -0,0 +1,71 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ModelActivityData } from "../types";
import KeyActivityPanel from "./KeyActivityPanel";
vi.mock("@/components/activity_metrics", () => ({
ActivityMetrics: ({ modelMetrics }: { modelMetrics: Record<string, ModelActivityData> }) => (
<ul data-testid="rendered-keys">
{Object.keys(modelMetrics).map((hash) => (
<li key={hash}>{hash}</li>
))}
</ul>
),
}));
function activity(label: string, user_email: string | null, user_id: string | null): ModelActivityData {
return {
label,
key_metadata: { key_alias: label, team_id: "team-1", user_id, user_email },
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 keyMetrics: Record<string, ModelActivityData> = {
"hash-alice": activity("alice-key", "alice@example.com", "user-alice"),
"hash-bob": activity("bob-key", "bob@example.com", "user-bob"),
};
describe("KeyActivityPanel", () => {
it("renders every key and the full count before searching", () => {
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob");
expect(screen.getByText("Showing 2 of 2 keys")).toBeInTheDocument();
});
it("narrows the rendered keys to those matching the user email", () => {
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "bob@example.com" } });
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-bob");
expect(screen.getByTestId("rendered-keys")).not.toHaveTextContent("hash-alice");
expect(screen.getByText("Showing 1 of 2 keys")).toBeInTheDocument();
});
it("shows an empty state instead of zeroed metrics when nothing matches", () => {
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "carol" } });
expect(screen.queryByTestId("rendered-keys")).not.toBeInTheDocument();
expect(screen.getByText('No keys match "carol" in this date range')).toBeInTheDocument();
});
it("clears the search and restores every key", () => {
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "user-alice" } });
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alice");
fireEvent.click(screen.getByLabelText("Clear key search"));
expect(screen.getByLabelText("Search keys")).toHaveValue("");
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob");
});
});

View file

@ -0,0 +1,58 @@
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 { filterKeyActivity } from "../keyActivityFilter";
import type { ModelActivityData } from "../types";
interface KeyActivityPanelProps {
keyMetrics: Record<string, ModelActivityData>;
hidePromptCachingMetrics?: boolean;
}
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>
);
};
export default KeyActivityPanel;

View file

@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { filterKeyActivity, keyActivityMatches } from "./keyActivityFilter";
import type { KeyMetadata, ModelActivityData } from "./types";
function activity(label: string, key_metadata?: KeyMetadata): ModelActivityData {
return {
label,
key_metadata,
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 aliceMeta: KeyMetadata = {
key_alias: "alice-batch",
team_id: "team-research",
user_id: "user-alice-1234",
user_email: "alice@example.com",
};
const bobMeta: KeyMetadata = {
key_alias: null,
team_id: "team-research",
user_id: "user-bob-5678",
user_email: "bob@example.com",
};
const alice = activity("alice-batch (team: research)", aliceMeta);
const bob = activity("bob@example.com (team: research)", bobMeta);
const orphan = activity("key-hash-deadbeef", { key_alias: null, team_id: null });
const keyMetrics: Record<string, ModelActivityData> = {
"hash-alice": alice,
"hash-bob": bob,
deadbeef: orphan,
};
describe("keyActivityMatches", () => {
it("matches every key on an empty or whitespace query", () => {
expect(keyActivityMatches("deadbeef", orphan, "")).toBe(true);
expect(keyActivityMatches("deadbeef", orphan, " ")).toBe(true);
});
it("matches key alias case-insensitively", () => {
expect(keyActivityMatches("hash-alice", alice, "ALICE-batch")).toBe(true);
expect(keyActivityMatches("hash-bob", bob, "alice-batch")).toBe(false);
});
it("matches user email", () => {
expect(keyActivityMatches("hash-bob", bob, "bob@example")).toBe(true);
expect(keyActivityMatches("hash-alice", alice, "bob@example")).toBe(false);
});
it("matches user id", () => {
expect(keyActivityMatches("hash-alice", alice, "user-alice-1234")).toBe(true);
expect(keyActivityMatches("hash-bob", bob, "user-alice-1234")).toBe(false);
});
it("matches the key hash when the key has no alias or user metadata", () => {
expect(keyActivityMatches("deadbeef", orphan, "dead")).toBe(true);
expect(keyActivityMatches("deadbeef", orphan, "alice")).toBe(false);
});
it("trims surrounding whitespace from the query", () => {
expect(keyActivityMatches("hash-alice", alice, " alice@example.com ")).toBe(true);
});
});
describe("filterKeyActivity", () => {
it("returns the same object when the query is blank", () => {
expect(filterKeyActivity(keyMetrics, "")).toBe(keyMetrics);
});
it("keeps only the keys matching the query, preserving their hashes", () => {
expect(Object.keys(filterKeyActivity(keyMetrics, "example.com"))).toEqual(["hash-alice", "hash-bob"]);
expect(filterKeyActivity(keyMetrics, "user-bob")).toEqual({ "hash-bob": bob });
});
it("returns an empty record when nothing matches", () => {
expect(filterKeyActivity(keyMetrics, "nobody")).toEqual({});
});
});

View file

@ -0,0 +1,20 @@
import type { ModelActivityData } from "./types";
export function keyActivityMatches(apiKey: string, data: ModelActivityData, query: string): boolean {
const needle = query.trim().toLowerCase();
if (needle === "") return true;
const meta = data.key_metadata;
return [apiKey, data.label, meta?.key_alias, meta?.user_id, meta?.user_email].some(
(field) => field?.toLowerCase().includes(needle) ?? false,
);
}
export function filterKeyActivity(
keyMetrics: Record<string, ModelActivityData>,
query: string,
): Record<string, ModelActivityData> {
if (query.trim() === "") return keyMetrics;
return Object.fromEntries(
Object.entries(keyMetrics).filter(([apiKey, data]) => keyActivityMatches(apiKey, data, query)),
);
}

View file

@ -46,6 +46,7 @@ export interface KeyMetricWithMetadata {
export interface KeyMetadata {
key_alias: string | null;
team_id: string | null;
user_id?: string | null;
user_email?: string | null;
tags?: { tag: string; usage: number }[];
}
@ -70,6 +71,7 @@ export interface TopModelData {
export interface ModelActivityData {
label: string;
key_metadata?: KeyMetadata;
total_requests: number;
total_successful_requests: number;
total_failed_requests: number;

View file

@ -655,6 +655,23 @@ describe("processActivityData", () => {
expect(result["key1"].label).toBe("test-key-1 (team_id: team1)");
});
it("retains the api key metadata so key activity can be searched by user", () => {
const metadata = { key_alias: "test-key-1", team_id: "team1", user_id: "user-1", user_email: "user1@example.com" };
const withUser: { results: DailyData[] } = {
results: [
createMockDailyData("2025-01-01", mockDailyActivity.results[0].metrics, {
...EMPTY_BREAKDOWN,
api_keys: { key1: createMockKeyMetricWithMetadata(metadata, mockDailyActivity.results[0].metrics) },
}),
],
};
const result = processActivityData(withUser, "api_keys", MOCK_TEAMS);
expect(result["key1"].key_metadata).toEqual(metadata);
expect(processActivityData(withUser, "models")["key1"]).toBeUndefined();
});
it("should process data for models key with data", () => {
const dailyActivityWithModels: { results: DailyData[] } = {
results: [

View file

@ -461,6 +461,7 @@ export const processActivityData = (
: key === "entities"
? (modelData as any).metadata?.agent_name || (modelData as any).metadata?.team_alias || model
: model,
...(key === "api_keys" ? { key_metadata: (modelData as KeyMetricWithMetadata).metadata } : {}),
total_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,

View file

@ -28414,6 +28414,8 @@ export interface components {
team_id?: string | null;
/** User Email */
user_email?: string | null;
/** User Id */
user_id?: string | null;
};
/**
* KeyMetricWithMetadata