mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
bd23e6fc3d
commit
eb385cca3e
6 changed files with 202 additions and 19 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 "{trimmedModelQuery}" in this date range
|
||||
</p>
|
||||
) : (
|
||||
<ActivityMetrics
|
||||
modelMetrics={filteredModelMetrics}
|
||||
fetchTopApiKeys={dailyActivityRequest ? fetchTopApiKeys : undefined}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="keys" keepMounted>
|
||||
<KeyActivityPanel
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue