From 0a90283fec19734c2139c0e073153e7a809f6368 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 7 Nov 2025 10:00:37 -0800 Subject: [PATCH] Change usage page to have a parent date picker (#16264) --- .../EntityUsageExport/UsageExportHeader.tsx | 24 +- .../src/components/entity_usage.test.tsx | 215 ++++++ .../src/components/entity_usage.tsx | 7 +- .../src/components/new_usage.test.tsx | 253 +++++++ .../src/components/new_usage.tsx | 693 +++++++++--------- .../shared/advanced_date_picker.test.tsx | 183 +++++ .../shared/advanced_date_picker.tsx | 31 +- .../components/user_agent_activity.test.tsx | 195 +++++ .../src/components/user_agent_activity.tsx | 28 +- 9 files changed, 1233 insertions(+), 396 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/entity_usage.test.tsx create mode 100644 ui/litellm-dashboard/src/components/new_usage.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx create mode 100644 ui/litellm-dashboard/src/components/user_agent_activity.test.tsx diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index 0a33fdb53d..1f61ea260e 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -1,14 +1,12 @@ import React, { useState } from "react"; import { Button, Text } from "@tremor/react"; import { Select } from "antd"; -import AdvancedDatePicker from "../shared/advanced_date_picker"; import EntityUsageExportModal from "./EntityUsageExportModal"; import type { DateRangePickerValue } from "@tremor/react"; import type { EntitySpendData } from "./types"; interface UsageExportHeaderProps { dateValue: DateRangePickerValue; - onDateChange: (value: DateRangePickerValue) => void; entityType: "tag" | "team"; spendData: EntitySpendData; // Optional filter props @@ -24,7 +22,6 @@ interface UsageExportHeaderProps { const UsageExportHeader: React.FC = ({ dateValue, - onDateChange, entityType, spendData, showFilters = false, @@ -38,23 +35,23 @@ const UsageExportHeader: React.FC = ({ }) => { const [isExportModalOpen, setIsExportModalOpen] = useState(false); + // Determine grid layout based on what's visible + const getGridCols = () => { + const hasFilters = showFilters && filterOptions.length > 0; + + if (hasFilters) return "grid-cols-[1fr_auto]"; + return "grid-cols-[auto]"; + }; + return ( <>
{/** - * Use CSS grid with items-end so all cells (date picker, filter, button) + * Use CSS grid with items-end so all cells (filter, button) * align to the same baseline regardless of label heights. This removes * vertical drift when the right column has a label above the input. */} -
0 ? "grid-cols-[1fr_1fr_auto]" : "grid-cols-[1fr_auto]" - } items-end gap-4`} - > -
- -
- +
{showFilters && filterOptions.length > 0 && (
{filterLabel && {filterLabel}} @@ -104,4 +101,3 @@ const UsageExportHeader: React.FC = ({ }; export default UsageExportHeader; - diff --git a/ui/litellm-dashboard/src/components/entity_usage.test.tsx b/ui/litellm-dashboard/src/components/entity_usage.test.tsx new file mode 100644 index 0000000000..f80b2fad5e --- /dev/null +++ b/ui/litellm-dashboard/src/components/entity_usage.test.tsx @@ -0,0 +1,215 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; +import EntityUsage from "./entity_usage"; +import * as networking from "./networking"; + +// Polyfill ResizeObserver for test environment +beforeAll(() => { + if (typeof window !== "undefined" && !window.ResizeObserver) { + window.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } as any; + } +}); + +// Mock the networking module +vi.mock("./networking", () => ({ + tagDailyActivityCall: vi.fn(), + teamDailyActivityCall: vi.fn(), +})); + +// Mock the child components to simplify testing +vi.mock("./activity_metrics", () => ({ + ActivityMetrics: () =>
Activity Metrics
, + processActivityData: () => ({ data: [], metadata: {} }), +})); + +vi.mock("./top_key_view", () => ({ + default: () =>
Top Keys
, +})); + +vi.mock("./top_model_view", () => ({ + default: () =>
Top Models
, +})); + +vi.mock("./EntityUsageExport", () => ({ + UsageExportHeader: () =>
Usage Export Header
, +})); + +describe("EntityUsage", () => { + const mockTagDailyActivityCall = vi.mocked(networking.tagDailyActivityCall); + const mockTeamDailyActivityCall = vi.mocked(networking.teamDailyActivityCall); + + const mockSpendData = { + results: [ + { + date: "2025-01-01", + metrics: { + spend: 100.5, + api_requests: 1000, + successful_requests: 950, + failed_requests: 50, + total_tokens: 50000, + prompt_tokens: 30000, + completion_tokens: 20000, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + breakdown: { + entities: { + "tag-1": { + metrics: { + spend: 60.3, + api_requests: 600, + successful_requests: 570, + failed_requests: 30, + total_tokens: 30000, + prompt_tokens: 18000, + completion_tokens: 12000, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + metadata: { + team_alias: "Tag 1", + }, + api_key_breakdown: {}, + }, + }, + models: {}, + api_keys: {}, + providers: { + openai: { + metrics: { + spend: 100.5, + api_requests: 1000, + successful_requests: 950, + failed_requests: 50, + total_tokens: 50000, + prompt_tokens: 30000, + completion_tokens: 20000, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }, + }, + }, + ], + metadata: { + total_spend: 100.5, + total_api_requests: 1000, + total_successful_requests: 950, + total_failed_requests: 50, + total_tokens: 50000, + }, + }; + + const defaultProps = { + accessToken: "test-token", + entityType: "tag" as const, + entityId: "test-tag", + userID: "user-123", + userRole: "Admin", + entityList: [ + { label: "Tag 1", value: "tag-1" }, + { label: "Tag 2", value: "tag-2" }, + ], + premiumUser: true, + dateValue: { + from: new Date("2025-01-01"), + to: new Date("2025-01-31"), + }, + }; + + beforeEach(() => { + mockTagDailyActivityCall.mockClear(); + mockTeamDailyActivityCall.mockClear(); + mockTagDailyActivityCall.mockResolvedValue(mockSpendData); + mockTeamDailyActivityCall.mockResolvedValue(mockSpendData); + }); + + it("should render with tag entity type and display spend metrics", async () => { + render(); + + await waitFor(() => { + expect(mockTagDailyActivityCall).toHaveBeenCalled(); + }); + + // Check that spend metrics are displayed + expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument(); + expect(screen.getByText("Total Spend")).toBeInTheDocument(); + + // Use getAllByText since $100.50 appears in multiple places + const spendElements = screen.getAllByText("$100.50"); + expect(spendElements.length).toBeGreaterThan(0); + + expect(screen.getByText("1,000")).toBeInTheDocument(); // Total Requests + }); + + it("should render with team entity type and call team API", async () => { + render(); + + await waitFor(() => { + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + }); + + // Check that it shows team-specific label + expect(screen.getByText("Team Spend Overview")).toBeInTheDocument(); + + // Use getAllByText since $100.50 appears in multiple places + const spendElements = screen.getAllByText("$100.50"); + expect(spendElements.length).toBeGreaterThan(0); + }); + + it("should switch between tabs", async () => { + render(); + + await waitFor(() => { + expect(mockTagDailyActivityCall).toHaveBeenCalled(); + }); + + // Check default tab (Cost) is shown + expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument(); + + // Click Model Activity tab + const modelActivityTab = screen.getByText("Model Activity"); + fireEvent.click(modelActivityTab); + + // Should show activity metrics + expect(screen.getAllByText("Activity Metrics")[0]).toBeInTheDocument(); + + // Click Key Activity tab + const keyActivityTab = screen.getByText("Key Activity"); + fireEvent.click(keyActivityTab); + + // Should show activity metrics again + expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument(); + }); + + it("should handle empty data gracefully", async () => { + const emptyData = { + results: [], + metadata: { + total_spend: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_tokens: 0, + }, + }; + + mockTagDailyActivityCall.mockResolvedValue(emptyData); + + render(); + + await waitFor(() => { + expect(mockTagDailyActivityCall).toHaveBeenCalled(); + }); + + // Check that zero values are displayed (component formats it as $0.00) + expect(screen.getByText("$0.00")).toBeInTheDocument(); + expect(screen.getByText("Total Spend")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/entity_usage.tsx b/ui/litellm-dashboard/src/components/entity_usage.tsx index 2a4229d692..fc1d03a372 100644 --- a/ui/litellm-dashboard/src/components/entity_usage.tsx +++ b/ui/litellm-dashboard/src/components/entity_usage.tsx @@ -74,6 +74,7 @@ interface EntityUsageProps { userRole: string | null; entityList: EntityList[] | null; premiumUser: boolean; + dateValue: DateRangePickerValue; } const EntityUsage: React.FC = ({ @@ -84,6 +85,7 @@ const EntityUsage: React.FC = ({ userRole, entityList, premiumUser, + dateValue, }) => { const [spendData, setSpendData] = useState({ results: [], @@ -99,10 +101,6 @@ const EntityUsage: React.FC = ({ const modelMetrics = processActivityData(spendData, "models"); const keyMetrics = processActivityData(spendData, "api_keys"); const [selectedTags, setSelectedTags] = useState([]); - const [dateValue, setDateValue] = useState({ - from: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000), - to: new Date(), - }); const fetchSpendData = async () => { if (!accessToken || !dateValue.from || !dateValue.to) return; @@ -331,7 +329,6 @@ const EntityUsage: React.FC = ({
0} diff --git a/ui/litellm-dashboard/src/components/new_usage.test.tsx b/ui/litellm-dashboard/src/components/new_usage.test.tsx new file mode 100644 index 0000000000..6c426401c5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/new_usage.test.tsx @@ -0,0 +1,253 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; +import NewUsagePage from "./new_usage"; +import * as networking from "./networking"; + +// Polyfill ResizeObserver for test environment +beforeAll(() => { + if (typeof window !== "undefined" && !window.ResizeObserver) { + window.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } as any; + } +}); + +// Mock the networking module +vi.mock("./networking", () => ({ + userDailyActivityCall: vi.fn(), + userDailyActivityAggregatedCall: vi.fn(), + tagListCall: vi.fn(), +})); + +// Mock child components to simplify testing +vi.mock("./activity_metrics", () => ({ + ActivityMetrics: () =>
Activity Metrics
, + processActivityData: () => ({ data: [], metadata: {} }), +})); + +vi.mock("./view_user_spend", () => ({ + default: () =>
View User Spend
, +})); + +vi.mock("./top_key_view", () => ({ + default: () =>
Top Keys
, +})); + +vi.mock("./entity_usage", () => ({ + default: () =>
Entity Usage
, + EntityList: [], +})); + +vi.mock("./user_agent_activity", () => ({ + default: () =>
User Agent Activity
, +})); + +vi.mock("./cloudzero_export_modal", () => ({ + default: () =>
CloudZero Export Modal
, +})); + +vi.mock("./EntityUsageExport", () => ({ + default: () =>
Entity Usage Export Modal
, +})); + +describe("NewUsage", () => { + const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall); + const mockTagListCall = vi.mocked(networking.tagListCall); + + const mockSpendData = { + results: [ + { + date: "2025-01-01", + metrics: { + spend: 125.75, + api_requests: 1500, + successful_requests: 1450, + failed_requests: 50, + total_tokens: 75000, + prompt_tokens: 45000, + completion_tokens: 30000, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + breakdown: { + models: { + "gpt-4": { + metrics: { + spend: 75.5, + api_requests: 800, + successful_requests: 780, + failed_requests: 20, + total_tokens: 40000, + prompt_tokens: 24000, + completion_tokens: 16000, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + metadata: {}, + api_key_breakdown: {}, + }, + }, + model_groups: { + "gpt-4": { + metrics: { + spend: 75.5, + api_requests: 800, + successful_requests: 780, + failed_requests: 20, + total_tokens: 40000, + prompt_tokens: 24000, + completion_tokens: 16000, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + metadata: {}, + api_key_breakdown: {}, + }, + }, + api_keys: { + "sk-test123": { + metrics: { + spend: 125.75, + api_requests: 1500, + successful_requests: 1450, + failed_requests: 50, + total_tokens: 75000, + prompt_tokens: 45000, + completion_tokens: 30000, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + metadata: { + key_alias: "Test Key", + tags: ["production"], + }, + }, + }, + providers: { + openai: { + metrics: { + spend: 125.75, + api_requests: 1500, + successful_requests: 1450, + failed_requests: 50, + total_tokens: 75000, + prompt_tokens: 45000, + completion_tokens: 30000, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }, + mcp_servers: {}, + }, + }, + ], + metadata: { + total_spend: 125.75, + total_api_requests: 1500, + total_successful_requests: 1450, + total_failed_requests: 50, + total_tokens: 75000, + }, + }; + + const defaultProps = { + accessToken: "test-token", + userRole: "Admin", + userID: "user-123", + teams: [ + { + team_id: "team-1", + team_alias: "Test Team", + models: [], + max_budget: null, + spend: 0, + tpm_limit: null, + rpm_limit: null, + blocked: false, + metadata: {}, + budget_duration: null, + organization_id: "org-123", + created_at: "2025-01-01T00:00:00Z", + keys: [], + members_with_roles: [], + }, + ], + premiumUser: true, + }; + + beforeEach(() => { + mockUserDailyActivityAggregatedCall.mockClear(); + mockTagListCall.mockClear(); + mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); + mockTagListCall.mockResolvedValue({}); + }); + + it("should render and fetch usage data on mount", async () => { + render(); + + // Wait for data to be fetched + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Check that key metrics are displayed + expect(screen.getByText("Total Requests")).toBeInTheDocument(); + expect(screen.getByText("1,500")).toBeInTheDocument(); + expect(screen.getByText("Successful Requests")).toBeInTheDocument(); + // Use getAllByText since this value appears in multiple places (metrics card + table) + const successfulRequestElements = screen.getAllByText("1,450"); + expect(successfulRequestElements.length).toBeGreaterThan(0); + }); + + it("should display usage metrics and charts", async () => { + render(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Check for usage metrics cards + expect(screen.getByText("Total Requests")).toBeInTheDocument(); + expect(screen.getByText("Successful Requests")).toBeInTheDocument(); + expect(screen.getByText("Failed Requests")).toBeInTheDocument(); + expect(screen.getByText("Total Tokens")).toBeInTheDocument(); + + // Check for chart titles + expect(screen.getByText("Daily Spend")).toBeInTheDocument(); + expect(screen.getByText("Top API Keys")).toBeInTheDocument(); + }); + + it("should switch between tabs correctly", async () => { + render(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Default tab should show Global Usage (for admin) + expect(screen.getByText("Daily Spend")).toBeInTheDocument(); + + // Switch to Team Usage tab + const teamUsageTab = screen.getByText("Team Usage"); + fireEvent.click(teamUsageTab); + + // Should render EntityUsage component + await waitFor(() => { + const entityUsageElements = screen.getAllByText("Entity Usage"); + expect(entityUsageElements.length).toBeGreaterThan(0); + }); + + // Switch to Tag Usage tab (admin only) + const tagUsageTab = screen.getByText("Tag Usage"); + fireEvent.click(tagUsageTab); + + // Should still render EntityUsage component for tags + await waitFor(() => { + const entityUsageElements = screen.getAllByText("Entity Usage"); + expect(entityUsageElements.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/new_usage.tsx b/ui/litellm-dashboard/src/components/new_usage.tsx index 2e85bc2b15..95d721ce9b 100644 --- a/ui/litellm-dashboard/src/components/new_usage.tsx +++ b/ui/litellm-dashboard/src/components/new_usage.tsx @@ -408,354 +408,373 @@ const NewUsagePage: React.FC = ({ accessToken, userRole, user
)} */} - - - {all_admin_roles.includes(userRole || "") ? Global Usage : Your Usage} - Team Usage - {all_admin_roles.includes(userRole || "") ? Tag Usage : <>} - {all_admin_roles.includes(userRole || "") ? User Agent Activity : <>} - - - {/* Your Usage Panel */} - - - - - - - -
- - Cost - Model Activity - Key Activity - MCP Server Activity - - -
- - {/* Cost Panel */} - - - {/* Total Spend Card */} - - - Project Spend{" "} - {new Date().toLocaleString("default", { - month: "long", - })}{" "} - 1 - {new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate()} - - - - - - - - Usage Metrics - - - Total Requests - - {userSpendData.metadata?.total_api_requests?.toLocaleString() || 0} - - - - Successful Requests - - {userSpendData.metadata?.total_successful_requests?.toLocaleString() || 0} - - - - Failed Requests - - {userSpendData.metadata?.total_failed_requests?.toLocaleString() || 0} - - - - Total Tokens - - {userSpendData.metadata?.total_tokens?.toLocaleString() || 0} - - - - Average Cost per Request - - $ - {formatNumberWithCommas( - (totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1), - 4, - )} - - - - - - - {/* Daily Spend Chart */} - - - Daily Spend - {loading ? ( - - ) : ( - new Date(a.date).getTime() - new Date(b.date).getTime(), + {/* Global Date Picker and Tabs - Single Row */} +
+
+ +
+ + {all_admin_roles.includes(userRole || "") ? Global Usage : Your Usage} + Team Usage + {all_admin_roles.includes(userRole || "") ? Tag Usage : <>} + {all_admin_roles.includes(userRole || "") ? User Agent Activity : <>} + + +
+ + {/* Your Usage Panel */} + + +
+ + Cost + Model Activity + Key Activity + MCP Server Activity + + +
+ + {/* Cost Panel */} + + + {/* Total Spend Card */} + + + Project Spend{" "} + {dateValue.from && dateValue.to && ( + <> + {dateValue.from.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: + dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined, + })} + {" - "} + {dateValue.to.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + )} - index="date" - categories={["metrics.spend"]} - colors={["cyan"]} - valueFormatter={valueFormatterSpend} - yAxisWidth={100} - showLegend={false} - customTooltip={({ payload, active }) => { - if (!active || !payload?.[0]) return null; - const data = payload[0].payload; - return ( -
-

{data.date}

-

- Spend: ${formatNumberWithCommas(data.metrics.spend, 2)} -

-

Requests: {data.metrics.api_requests}

-

Successful: {data.metrics.successful_requests}

-

Failed: {data.metrics.failed_requests}

-

Tokens: {data.metrics.total_tokens}

-
- ); - }} - /> - )} - - - {/* Top API Keys */} - - - Top API Keys - - - +
- {/* Top Models */} - - -
- {modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"} -
- - -
-
- {loading ? ( - - ) : ( - { - if (!active || !payload?.[0]) return null; - const data = payload[0].payload; - return ( -
-

{data.key}

-

Spend: ${formatNumberWithCommas(data.spend, 2)}

-

Total Requests: {data.requests.toLocaleString()}

-

- Successful: {data.successful_requests.toLocaleString()} -

-

Failed: {data.failed_requests.toLocaleString()}

-

Tokens: {data.tokens.toLocaleString()}

-
- ); - }} + - )} -
- + - {/* Spend by Provider */} - - -
- Spend by Provider -
- {loading ? ( - - ) : ( - - - `$${formatNumberWithCommas(value, 2)}`} + + + Usage Metrics + + + Total Requests + + {userSpendData.metadata?.total_api_requests?.toLocaleString() || 0} + + + + Successful Requests + + {userSpendData.metadata?.total_successful_requests?.toLocaleString() || 0} + + + + Failed Requests + + {userSpendData.metadata?.total_failed_requests?.toLocaleString() || 0} + + + + Total Tokens + + {userSpendData.metadata?.total_tokens?.toLocaleString() || 0} + + + + Average Cost per Request + + $ + {formatNumberWithCommas( + (totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1), + 4, + )} + + + + + + + {/* Daily Spend Chart */} + + + Daily Spend + {loading ? ( + + ) : ( + new Date(a.date).getTime() - new Date(b.date).getTime(), + )} + index="date" + categories={["metrics.spend"]} colors={["cyan"]} + valueFormatter={valueFormatterSpend} + yAxisWidth={100} + showLegend={false} + customTooltip={({ payload, active }) => { + if (!active || !payload?.[0]) return null; + const data = payload[0].payload; + return ( +
+

{data.date}

+

+ Spend: ${formatNumberWithCommas(data.metrics.spend, 2)} +

+

Requests: {data.metrics.api_requests}

+

Successful: {data.metrics.successful_requests}

+

Failed: {data.metrics.failed_requests}

+

Tokens: {data.metrics.total_tokens}

+
+ ); + }} /> - - - - - - Provider - Spend - Successful - Failed - Tokens - - - - {getProviderSpend() - .filter((provider) => provider.spend > 0) - .map((provider) => ( - - -
- {provider.provider && ( - {`${provider.provider} { - const target = e.target as HTMLImageElement; - const parent = target.parentElement; - if (parent) { - const fallbackDiv = document.createElement("div"); - fallbackDiv.className = - "w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs"; - fallbackDiv.textContent = provider.provider?.charAt(0) || "-"; - parent.replaceChild(fallbackDiv, target); - } - }} - /> - )} - {provider.provider} -
-
- ${formatNumberWithCommas(provider.spend, 2)} - - {provider.successful_requests.toLocaleString()} - - - {provider.failed_requests.toLocaleString()} - - {provider.tokens.toLocaleString()} + )} + + + {/* Top API Keys */} +
+ + Top API Keys + + + + + {/* Top Models */} + + +
+ + {modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"} + +
+ + +
+
+ {loading ? ( + + ) : ( + { + if (!active || !payload?.[0]) return null; + const data = payload[0].payload; + return ( +
+

{data.key}

+

Spend: ${formatNumberWithCommas(data.spend, 2)}

+

Total Requests: {data.requests.toLocaleString()}

+

+ Successful: {data.successful_requests.toLocaleString()} +

+

Failed: {data.failed_requests.toLocaleString()}

+

Tokens: {data.tokens.toLocaleString()}

+
+ ); + }} + /> + )} +
+ + + {/* Spend by Provider */} + + +
+ Spend by Provider +
+ {loading ? ( + + ) : ( + +
+ `$${formatNumberWithCommas(value, 2)}`} + colors={["cyan"]} + /> + + +
+ + + Provider + Spend + Successful + Failed + Tokens - ))} - -
- -
- )} -
- + + + {getProviderSpend() + .filter((provider) => provider.spend > 0) + .map((provider) => ( + + +
+ {provider.provider && ( + {`${provider.provider} { + const target = e.target as HTMLImageElement; + const parent = target.parentElement; + if (parent) { + const fallbackDiv = document.createElement("div"); + fallbackDiv.className = + "w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs"; + fallbackDiv.textContent = provider.provider?.charAt(0) || "-"; + parent.replaceChild(fallbackDiv, target); + } + }} + /> + )} + {provider.provider} +
+
+ ${formatNumberWithCommas(provider.spend, 2)} + + {provider.successful_requests.toLocaleString()} + + + {provider.failed_requests.toLocaleString()} + + {provider.tokens.toLocaleString()} +
+ ))} +
+ + +
+ )} + + - {/* Usage Metrics */} - -
+ {/* Usage Metrics */} + +
- {/* Activity Panel */} - - - - - - - - - -
-
- + {/* Activity Panel */} + + + + + + + + + + + + - {/* Team Usage Panel */} - - ({ - label: team.team_alias, - value: team.team_id, - })) || null - } - premiumUser={premiumUser} - /> - + {/* Team Usage Panel */} + + ({ + label: team.team_alias, + value: team.team_id, + })) || null + } + premiumUser={premiumUser} + dateValue={dateValue} + /> + - {/* Tag Usage Panel */} - - - - {/* User Agent Activity Panel */} - - - - - + {/* Tag Usage Panel */} + + + + {/* User Agent Activity Panel */} + + + + + +
+
{/* CloudZero Export Modal */} { + if (typeof window !== "undefined" && !window.requestIdleCallback) { + window.requestIdleCallback = (callback: any) => { + const start = Date.now(); + return setTimeout(() => { + callback({ + didTimeout: false, + timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), + }); + }, 1) as any; + }; + } +}); + +describe("AdvancedDatePicker", () => { + const mockOnValueChange = vi.fn(); + const defaultValue = { + from: new Date("2025-01-01T12:00:00.000Z"), + to: new Date("2025-01-31T12:00:00.000Z"), + }; + + beforeEach(() => { + mockOnValueChange.mockClear(); + }); + + const openDropdown = (container: HTMLElement) => { + // Find the clickable div that contains the clock icon + const trigger = container.querySelector('[role="img"][aria-label="clock-circle"]')?.closest("div.cursor-pointer"); + if (trigger) { + fireEvent.click(trigger); + } + }; + + it("should render with default label", () => { + render(); + expect(screen.getByText("Select Time Range")).toBeInTheDocument(); + }); + + it("should render with custom label", () => { + render(); + expect(screen.getByText("Custom Label")).toBeInTheDocument(); + }); + + it("should display formatted date range", () => { + render(); + // The component displays date range in the format "D MMM, HH:mm - D MMM, HH:mm" + // Just check that the clock icon is present + expect(screen.getByLabelText("clock-circle")).toBeInTheDocument(); + }); + + it("should open dropdown when clicked", () => { + const { container } = render(); + + openDropdown(container); + + // Check for relative time options + expect(screen.getByText("Today")).toBeInTheDocument(); + expect(screen.getByText("Last 7 days")).toBeInTheDocument(); + expect(screen.getByText("Last 30 days")).toBeInTheDocument(); + }); + + it("should display relative time options", () => { + const { container } = render(); + + openDropdown(container); + + expect(screen.getByText("Today")).toBeInTheDocument(); + expect(screen.getByText("Last 7 days")).toBeInTheDocument(); + expect(screen.getByText("Last 30 days")).toBeInTheDocument(); + expect(screen.getByText("Month to date")).toBeInTheDocument(); + expect(screen.getByText("Year to date")).toBeInTheDocument(); + }); + + it("should show date inputs in dropdown", () => { + const { container } = render(); + + openDropdown(container); + + const startDateInput = screen.getByDisplayValue("2025-01-01"); + const endDateInput = screen.getByDisplayValue("2025-01-31"); + + expect(startDateInput).toBeInTheDocument(); + expect(endDateInput).toBeInTheDocument(); + }); + + it("should update date inputs when changed", () => { + const { container } = render(); + + openDropdown(container); + + const startDateInput = screen.getByDisplayValue("2025-01-01") as HTMLInputElement; + fireEvent.change(startDateInput, { target: { value: "2025-02-01" } }); + + expect(startDateInput.value).toBe("2025-02-01"); + }); + + it("should show Apply and Cancel buttons", () => { + const { container } = render(); + + openDropdown(container); + + expect(screen.getByText("Apply")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + + it("should close dropdown when Cancel is clicked", () => { + const { container } = render(); + + openDropdown(container); + + const cancelButton = screen.getByText("Cancel"); + fireEvent.click(cancelButton); + + // Dropdown should be closed, so relative time options shouldn't be visible + expect(screen.queryByText("Today")).not.toBeInTheDocument(); + }); + + it("should call onValueChange when Apply is clicked", async () => { + const { container } = render(); + + openDropdown(container); + + const applyButton = screen.getByText("Apply"); + fireEvent.click(applyButton); + + await waitFor(() => { + expect(mockOnValueChange).toHaveBeenCalled(); + }); + }); + + it("should select relative time option", () => { + const { container } = render(); + + openDropdown(container); + + const todayOption = screen.getByText("Today"); + fireEvent.click(todayOption); + + // The option should be highlighted (bg-blue-50) + expect(todayOption.closest("div")).toHaveClass("bg-blue-50"); + }); + + it("should show validation error for invalid date range", async () => { + const { container } = render(); + + openDropdown(container); + + const startDateInput = screen.getByDisplayValue("2025-01-01"); + const endDateInput = screen.getByDisplayValue("2025-01-31"); + + // Set end date before start date + fireEvent.change(startDateInput, { target: { value: "2025-12-01" } }); + fireEvent.change(endDateInput, { target: { value: "2025-01-01" } }); + + await waitFor(() => { + expect(screen.getByText("End date cannot be before start date")).toBeInTheDocument(); + }); + }); + + it("should disable Apply button when validation fails", async () => { + const { container } = render(); + + openDropdown(container); + + const startDateInput = screen.getByDisplayValue("2025-01-01"); + const endDateInput = screen.getByDisplayValue("2025-01-31"); + + // Set end date before start date + fireEvent.change(startDateInput, { target: { value: "2025-12-01" } }); + fireEvent.change(endDateInput, { target: { value: "2025-01-01" } }); + + await waitFor(() => { + // Find the button element (the Apply button's actual button element) + const applyButton = screen.getByText("Apply").closest("button"); + expect(applyButton).toBeDisabled(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx index aeb867b859..d9e70ef333 100644 --- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx +++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx @@ -273,9 +273,8 @@ const AdvancedDatePicker: React.FC = ({ }; return ( -
- {label && {label}} - +
+ {label && {label}}
{/* Main input display */}
= ({ {/* Left side - Relative time options */}
- Relative time (today, 7d, 30d, MTD, YTD) + Relative time
{relativeTimeOptions.map((option) => { @@ -322,7 +321,7 @@ const AdvancedDatePicker: React.FC = ({ {option.label} @@ -391,11 +390,17 @@ const AdvancedDatePicker: React.FC = ({
)} - {/* Current selection preview */} + {/* Current selection time range */} {tempValue.from && tempValue.to && validation.isValid && ( -
-
Preview:
-
{formatDisplayRange(tempValue.from, tempValue.to)}
+
+
+ From:{" "} + {moment(tempValue.from).format("MMM D, YYYY [at] HH:mm:ss")} +
+
+ To:{" "} + {moment(tempValue.to).format("MMM D, YYYY [at] HH:mm:ss")} +
)}
@@ -415,14 +420,6 @@ const AdvancedDatePicker: React.FC = ({
)}
- - {/* Time range display below */} - {showTimeRange && value.from && value.to && ( - - {moment(value.from).format("MMM D, YYYY [at] HH:mm:ss")} -{" "} - {moment(value.to).format("MMM D, YYYY [at] HH:mm:ss")} - - )}
); }; diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx new file mode 100644 index 0000000000..7b67720e9e --- /dev/null +++ b/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx @@ -0,0 +1,195 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; +import UserAgentActivity from "./user_agent_activity"; +import * as networking from "./networking"; + +// Polyfill ResizeObserver for test environment +beforeAll(() => { + if (typeof window !== "undefined" && !window.ResizeObserver) { + window.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } as any; + } +}); + +// Mock the networking module +vi.mock("./networking", () => ({ + userAgentSummaryCall: vi.fn(), + tagDauCall: vi.fn(), + tagWauCall: vi.fn(), + tagMauCall: vi.fn(), + tagDistinctCall: vi.fn(), +})); + +// Mock PerUserUsage component +vi.mock("./per_user_usage", () => ({ + default: () =>
Per User Usage
, +})); + +describe("UserAgentActivity", () => { + const mockUserAgentSummaryCall = vi.mocked(networking.userAgentSummaryCall); + const mockTagDauCall = vi.mocked(networking.tagDauCall); + const mockTagWauCall = vi.mocked(networking.tagWauCall); + const mockTagMauCall = vi.mocked(networking.tagMauCall); + const mockTagDistinctCall = vi.mocked(networking.tagDistinctCall); + + const mockDistinctTagsData = { + results: [{ tag: "User-Agent: Chrome/1.0" }, { tag: "User-Agent: Firefox/2.0" }, { tag: "User-Agent: Safari/3.0" }], + }; + + const mockSummaryData = { + results: [ + { + tag: "User-Agent: Chrome/1.0", + unique_users: 100, + total_requests: 1000, + successful_requests: 950, + failed_requests: 50, + total_tokens: 50000, + total_spend: 25.5, + }, + { + tag: "User-Agent: Firefox/2.0", + unique_users: 80, + total_requests: 800, + successful_requests: 760, + failed_requests: 40, + total_tokens: 40000, + total_spend: 20.3, + }, + ], + }; + + const mockDauData = { + results: [ + { + tag: "User-Agent: Chrome/1.0", + active_users: 50, + date: "2025-01-01", + }, + { + tag: "User-Agent: Firefox/2.0", + active_users: 30, + date: "2025-01-01", + }, + ], + }; + + const mockWauData = { + results: [ + { + tag: "User-Agent: Chrome/1.0", + active_users: 200, + date: "Week 1 (Jan 1)", + }, + ], + }; + + const mockMauData = { + results: [ + { + tag: "User-Agent: Chrome/1.0", + active_users: 500, + date: "Month 1 (Jan)", + }, + ], + }; + + const defaultProps = { + accessToken: "test-token", + userRole: "Admin", + dateValue: { + from: new Date("2025-01-01"), + to: new Date("2025-01-31"), + }, + }; + + beforeEach(() => { + mockUserAgentSummaryCall.mockClear(); + mockTagDauCall.mockClear(); + mockTagWauCall.mockClear(); + mockTagMauCall.mockClear(); + mockTagDistinctCall.mockClear(); + + mockTagDistinctCall.mockResolvedValue(mockDistinctTagsData); + mockUserAgentSummaryCall.mockResolvedValue(mockSummaryData); + mockTagDauCall.mockResolvedValue(mockDauData); + mockTagWauCall.mockResolvedValue(mockWauData); + mockTagMauCall.mockResolvedValue(mockMauData); + }); + + it("should render summary cards with user agent data", async () => { + render(); + + // Wait for data to load + await waitFor(() => { + expect(mockUserAgentSummaryCall).toHaveBeenCalled(); + expect(mockTagDistinctCall).toHaveBeenCalled(); + }); + + // Check that summary section is displayed + expect(screen.getByText("Summary by User Agent")).toBeInTheDocument(); + expect(screen.getByText("Performance metrics for different user agents")).toBeInTheDocument(); + + // Check that user agent cards are displayed + await waitFor(() => { + expect(screen.getByText("Chrome/1.0")).toBeInTheDocument(); + expect(screen.getByText("Firefox/2.0")).toBeInTheDocument(); + }); + + // Check that metrics are displayed + expect(screen.getAllByText("Success Requests").length).toBeGreaterThan(0); + expect(screen.getAllByText("Total Tokens").length).toBeGreaterThan(0); + expect(screen.getAllByText("Total Cost").length).toBeGreaterThan(0); + }); + + it("should switch between DAU, WAU, and MAU tabs", async () => { + render(); + + // Wait for data to load + await waitFor(() => { + expect(mockTagDauCall).toHaveBeenCalled(); + expect(mockTagWauCall).toHaveBeenCalled(); + expect(mockTagMauCall).toHaveBeenCalled(); + }); + + // Check default DAU tab content + expect(screen.getByText("Daily Active Users - Last 7 Days")).toBeInTheDocument(); + + // Find all WAU tab buttons (there might be multiple) + const wauTabs = screen.getAllByText("WAU"); + fireEvent.click(wauTabs[0]); + + // Check WAU tab content + await waitFor(() => { + expect(screen.getByText("Weekly Active Users - Last 7 Weeks")).toBeInTheDocument(); + }); + + // Find all MAU tab buttons + const mauTabs = screen.getAllByText("MAU"); + fireEvent.click(mauTabs[0]); + + // Check MAU tab content + await waitFor(() => { + expect(screen.getByText("Monthly Active Users - Last 7 Months")).toBeInTheDocument(); + }); + }); + + it("should display filter dropdown and allow tag selection", async () => { + render(); + + // Wait for tags to load + await waitFor(() => { + expect(mockTagDistinctCall).toHaveBeenCalled(); + }); + + // Check that filter label is present + expect(screen.getByText("Filter by User Agents")).toBeInTheDocument(); + + // The Ant Design Select component should be in the document with placeholder + const selectElement = screen.getByText("All User Agents"); + expect(selectElement).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.tsx index 492c7fbcd8..8b5f303670 100644 --- a/ui/litellm-dashboard/src/components/user_agent_activity.tsx +++ b/ui/litellm-dashboard/src/components/user_agent_activity.tsx @@ -15,7 +15,6 @@ import { } from "@tremor/react"; import { Select, Tooltip } from "antd"; import { userAgentSummaryCall, tagDauCall, tagWauCall, tagMauCall, tagDistinctCall } from "./networking"; -import AdvancedDatePicker from "./shared/advanced_date_picker"; import PerUserUsage from "./per_user_usage"; import { DateRangePickerValue } from "@tremor/react"; import { ChartLoader } from "./shared/chart_loader"; @@ -58,9 +57,11 @@ interface DistinctTagsResponse { interface UserAgentActivityProps { accessToken: string | null; userRole: string | null; + dateValue: DateRangePickerValue; + onDateChange?: (value: DateRangePickerValue) => void; // Optional - not used anymore } -const UserAgentActivity: React.FC = ({ accessToken, userRole }) => { +const UserAgentActivity: React.FC = ({ accessToken, userRole, dateValue, onDateChange }) => { // Maximum number of categories to show in charts to prevent color palette overflow const MAX_CATEGORIES = 10; @@ -70,11 +71,6 @@ const UserAgentActivity: React.FC = ({ accessToken, user const [mauData, setMauData] = useState({ results: [] }); const [summaryData, setSummaryData] = useState({ results: [] }); - const [dateValue, setDateValue] = useState({ - from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), - to: new Date(), - }); - const [userAgentFilter, setUserAgentFilter] = useState(""); // Tag filtering state @@ -88,8 +84,6 @@ const UserAgentActivity: React.FC = ({ accessToken, user const [mauLoading, setMauLoading] = useState(false); const [summaryLoading, setSummaryLoading] = useState(false); - const [isDateChanging, setIsDateChanging] = useState(false); - // Use today's date as the end date for all API calls const today = new Date(); @@ -180,20 +174,9 @@ const UserAgentActivity: React.FC = ({ accessToken, user console.error("Failed to fetch user agent summary data:", error); } finally { setSummaryLoading(false); - setIsDateChanging(false); } }; - // Super responsive date change handler - const handleDateChange = (newValue: DateRangePickerValue) => { - // Instant visual feedback - setIsDateChanging(true); - setSummaryLoading(true); - - // Update date immediately for UI responsiveness - setDateValue(newValue); - }; - // Effect to fetch available tags on mount useEffect(() => { fetchAvailableTags(); @@ -425,12 +408,11 @@ const UserAgentActivity: React.FC = ({ accessToken, user
- {/* Date Range Picker within Summary */} - + {/* Date Range Picker is controlled by parent component */} {/* Top 4 User Agents Cards */} {summaryLoading ? ( - + ) : ( {(summaryData.results || []).slice(0, 4).map((tag, index) => {