mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
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:
parent
dbc6c1cfaa
commit
1577b65715
8 changed files with 264 additions and 45 deletions
|
|
@ -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"} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 "{query.trim()}" in this date range
|
||||
</p>
|
||||
) : (
|
||||
<ActivityMetrics modelMetrics={filtered} hidePromptCachingMetrics={hidePromptCachingMetrics} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivitySearchPanel;
|
||||
|
|
@ -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 "{query.trim()}" 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue