diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx new file mode 100644 index 00000000000..a482b1007df --- /dev/null +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx @@ -0,0 +1,121 @@ +/** + * Tests for EntityUsageExportModal component + * + * Validates core export functionality: + * - Renders modal with correct default state (CSV format, daily scope) + * - User can select export type (daily vs daily_with_models) + * - User can switch format (CSV vs JSON) + * - Export button triggers data generation with correct parameters + * - Modal closes after successful export + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import EntityUsageExportModal from "./EntityUsageExportModal"; + +// Mock utilities that format/export data so tests stay fast and deterministic +vi.mock("./utils", () => { + return { + generateExportData: vi.fn(() => [{ Date: "2025-10-01" }]), + generateMetadata: vi.fn(() => ({ meta: true })), + }; +}); + +// Mock notifications +vi.mock("../molecules/notifications_manager", () => { + return { + default: { + success: vi.fn(), + fromBackend: vi.fn(), + info: vi.fn(), + }, + }; +}); + +// JSDOM stubs for download flow used by the modal +// @ts-ignore +global.URL.createObjectURL = vi.fn(() => "blob:mock"); +// @ts-ignore +global.URL.revokeObjectURL = vi.fn(); + +describe("EntityUsageExportModal", () => { + const baseProps = { + isOpen: true, + onClose: vi.fn(), + entityType: "tag" as const, + spendData: { + results: [], + metadata: { + total_spend: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_tokens: 0, + }, + }, + dateRange: { from: new Date("2025-10-01"), to: new Date("2025-10-14") }, + selectedFilters: [], + customTitle: "Export Tag Usage", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders default state and exports CSV (daily) successfully", async () => { + /** + * Tests the happy path: user opens modal and exports with defaults. + * Verifies that generateExportData is called with 'daily' scope + * and modal closes after export completes. + */ + const user = userEvent.setup(); + const { generateExportData } = await import("./utils"); + + render(); + + // Default primary action reflects CSV export + expect(screen.getByRole("button", { name: /Export CSV/i })).toBeInTheDocument(); + + // Click export + await user.click(screen.getByRole("button", { name: /Export CSV/i })); + + // Verifies export pipeline was invoked with default scope 'daily' + expect(generateExportData).toHaveBeenCalled(); + const callArgs = (generateExportData as any).mock.calls[0]; + expect(callArgs[1]).toBe("daily"); + + // Modal closes after export + expect(baseProps.onClose).toHaveBeenCalled(); + }); + + it("exports with 'day-by-day by tag and model' scope when selected", async () => { + /** + * Tests that user can change export type (scope). + * Verifies generateExportData receives 'daily_with_models' scope + * when the second radio option is selected. + */ + const user = userEvent.setup(); + const { generateExportData } = await import("./utils"); + + render(); + + // Choose the alternate export type - click the label to trigger radio + const dailyModelLabel = screen.getByText(/Day-by-day by tag and model/i); + await user.click(dailyModelLabel); + + // Export with default CSV format + const exportBtn = screen.getByRole("button", { name: /Export CSV/i }); + await user.click(exportBtn); + + // Ensure the selected scope flowed through + expect(generateExportData).toHaveBeenCalled(); + const callArgs = (generateExportData as any).mock.calls.at(-1); + expect(callArgs[1]).toBe("daily_with_models"); + + // Modal closes after export + expect(baseProps.onClose).toHaveBeenCalled(); + }); +}); + + diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx new file mode 100644 index 00000000000..bd9adb6d889 --- /dev/null +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx @@ -0,0 +1,113 @@ +import React, { useState } from "react"; +import { Button } from "@tremor/react"; +import { Modal } from "antd"; +import Papa from "papaparse"; +import NotificationsManager from "../molecules/notifications_manager"; +import ExportSummary from "./ExportSummary"; +import ExportTypeSelector from "./ExportTypeSelector"; +import ExportFormatSelector from "./ExportFormatSelector"; +import { generateExportData, generateMetadata } from "./utils"; +import type { EntityUsageExportModalProps, ExportFormat, ExportScope } from "./types"; + +const EntityUsageExportModal: React.FC = ({ + isOpen, + onClose, + entityType, + spendData, + dateRange, + selectedFilters, + customTitle, +}) => { + const [exportFormat, setExportFormat] = useState("csv"); + const [exportScope, setExportScope] = useState("daily"); + const [isExporting, setIsExporting] = useState(false); + + const entityLabel = entityType === "tag" ? "Tag" : "Team"; + const modalTitle = customTitle || `Export ${entityLabel} Usage`; + + const handleExportCSV = () => { + const data = generateExportData(spendData, exportScope, entityLabel); + const csv = Papa.unparse(data); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + const fileName = `${entityType}_usage_${exportScope}_${new Date().toISOString().split("T")[0]}.csv`; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + }; + + const handleExportJSON = () => { + const data = generateExportData(spendData, exportScope, entityLabel); + const metadata = generateMetadata(entityType, dateRange, selectedFilters, exportScope, spendData); + const exportObject = { + metadata, + data, + }; + const jsonString = JSON.stringify(exportObject, null, 2); + const blob = new Blob([jsonString], { type: "application/json" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + const fileName = `${entityType}_usage_${exportScope}_${new Date().toISOString().split("T")[0]}.json`; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + }; + + const handleExport = async (format?: ExportFormat) => { + const formatToUse = format || exportFormat; + setIsExporting(true); + try { + if (formatToUse === "csv") { + handleExportCSV(); + NotificationsManager.success(`${entityLabel} usage data exported successfully as CSV`); + } else { + handleExportJSON(); + NotificationsManager.success(`${entityLabel} usage data exported successfully as JSON`); + } + onClose(); + } catch (error) { + console.error("Error exporting data:", error); + NotificationsManager.fromBackend("Failed to export data"); + } finally { + setIsExporting(false); + } + }; + + return ( + {modalTitle}} + open={isOpen} + onCancel={onClose} + footer={null} + width={480} + destroyOnClose + > +
+ + + + + + +
+ + +
+
+
+ ); +}; + +export default EntityUsageExportModal; + diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportFormatSelector.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportFormatSelector.tsx new file mode 100644 index 00000000000..b27bbfd6762 --- /dev/null +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportFormatSelector.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Select } from "antd"; +import type { ExportFormat } from "./types"; + +interface ExportFormatSelectorProps { + value: ExportFormat; + onChange: (value: ExportFormat) => void; +} + +const ExportFormatSelector: React.FC = ({ value, onChange }) => { + return ( +
+ + +
+ )} + +
+ +
+ + + + setIsExportModalOpen(false)} + entityType={entityType} + spendData={spendData} + dateRange={dateValue} + selectedFilters={selectedFilters} + customTitle={customTitle} + /> + + ); +}; + +export default UsageExportHeader; + diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/index.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/index.ts new file mode 100644 index 00000000000..e205a7fb849 --- /dev/null +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/index.ts @@ -0,0 +1,4 @@ +export { default } from "./EntityUsageExportModal"; +export { default as UsageExportHeader } from "./UsageExportHeader"; +export * from "./types"; + diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts new file mode 100644 index 00000000000..b7ac41c6f33 --- /dev/null +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts @@ -0,0 +1,62 @@ +import type { DateRangePickerValue } from "@tremor/react"; + +export type ExportFormat = "csv" | "json"; +export type ExportScope = "daily" | "daily_with_models"; + +export interface EntitySpendData { + results: any[]; + metadata: { + total_spend: number; + total_api_requests: number; + total_successful_requests: number; + total_failed_requests: number; + total_tokens: number; + }; +} + +export interface EntityUsageExportModalProps { + isOpen: boolean; + onClose: () => void; + entityType: "tag" | "team"; + spendData: EntitySpendData; + dateRange: DateRangePickerValue; + selectedFilters: string[]; + customTitle?: string; +} + +export interface ExportMetadata { + export_date: string; + entity_type: string; + date_range: { + from?: string; + to?: string; + }; + filters_applied: string[] | string; + export_scope: ExportScope; + summary: { + total_spend: number; + total_requests: number; + successful_requests: number; + failed_requests: number; + total_tokens: number; + }; +} + +export interface EntityBreakdown { + metrics: { + spend: number; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + api_requests: number; + successful_requests: number; + failed_requests: number; + cache_read_input_tokens: number; + cache_creation_input_tokens: number; + }; + metadata: { + alias: string; + id: string; + }; +} + diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts new file mode 100644 index 00000000000..a63a60e5cb4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -0,0 +1,162 @@ +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import type { EntitySpendData, EntityBreakdown, ExportMetadata, ExportScope } from "./types"; + +export const getEntityBreakdown = (spendData: EntitySpendData): EntityBreakdown[] => { + const entitySpend: { [key: string]: EntityBreakdown } = {}; + + spendData.results.forEach((day) => { + Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => { + if (!entitySpend[entity]) { + entitySpend[entity] = { + metrics: { + spend: 0, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + api_requests: 0, + successful_requests: 0, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + metadata: { + alias: data.metadata?.team_alias || entity, + id: entity, + }, + }; + } + entitySpend[entity].metrics.spend += data.metrics.spend; + entitySpend[entity].metrics.api_requests += data.metrics.api_requests; + entitySpend[entity].metrics.successful_requests += data.metrics.successful_requests; + entitySpend[entity].metrics.failed_requests += data.metrics.failed_requests; + entitySpend[entity].metrics.total_tokens += data.metrics.total_tokens; + entitySpend[entity].metrics.prompt_tokens += data.metrics.prompt_tokens || 0; + entitySpend[entity].metrics.completion_tokens += data.metrics.completion_tokens || 0; + entitySpend[entity].metrics.cache_read_input_tokens += data.metrics.cache_read_input_tokens || 0; + entitySpend[entity].metrics.cache_creation_input_tokens += data.metrics.cache_creation_input_tokens || 0; + }); + }); + + return Object.values(entitySpend).sort((a, b) => b.metrics.spend - a.metrics.spend); +}; + +export const generateDailyData = (spendData: EntitySpendData, entityLabel: string): any[] => { + const dailyBreakdown: any[] = []; + + spendData.results.forEach((day) => { + Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => { + dailyBreakdown.push({ + Date: day.date, + [entityLabel]: data.metadata?.team_alias || entity, + [`${entityLabel} ID`]: entity, + "Spend ($)": formatNumberWithCommas(data.metrics.spend, 4), + "Requests": data.metrics.api_requests, + "Successful Requests": data.metrics.successful_requests, + "Failed Requests": data.metrics.failed_requests, + "Total Tokens": data.metrics.total_tokens, + "Prompt Tokens": data.metrics.prompt_tokens || 0, + "Completion Tokens": data.metrics.completion_tokens || 0, + }); + }); + }); + + return dailyBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime()); +}; + +export const generateDailyWithModelsData = (spendData: EntitySpendData, entityLabel: string): any[] => { + const dailyModelBreakdown: any[] = []; + + spendData.results.forEach((day) => { + const dailyEntityModels: { [key: string]: { [key: string]: any } } = {}; + + Object.entries(day.breakdown.entities || {}).forEach(([entity, entityData]: [string, any]) => { + const entityName = entityData.metadata?.team_alias || entity; + + if (!dailyEntityModels[entity]) { + dailyEntityModels[entity] = {}; + } + + Object.entries(day.breakdown.models || {}).forEach(([model, modelData]: [string, any]) => { + const apiKeyBreakdown = entityData.api_key_breakdown || {}; + + Object.entries(apiKeyBreakdown).forEach(([apiKey, apiKeyData]: [string, any]) => { + if (!dailyEntityModels[entity][model]) { + dailyEntityModels[entity][model] = { + spend: 0, + requests: 0, + successful: 0, + failed: 0, + tokens: 0, + }; + } + dailyEntityModels[entity][model].spend += apiKeyData.metrics.spend || 0; + dailyEntityModels[entity][model].requests += apiKeyData.metrics.api_requests || 0; + dailyEntityModels[entity][model].successful += apiKeyData.metrics.successful_requests || 0; + dailyEntityModels[entity][model].failed += apiKeyData.metrics.failed_requests || 0; + dailyEntityModels[entity][model].tokens += apiKeyData.metrics.total_tokens || 0; + }); + }); + }); + + Object.entries(dailyEntityModels).forEach(([entity, models]) => { + const entityData = day.breakdown.entities?.[entity]; + const entityName = entityData?.metadata?.team_alias || entity; + + Object.entries(models).forEach(([model, metrics]: [string, any]) => { + dailyModelBreakdown.push({ + Date: day.date, + [entityLabel]: entityName, + [`${entityLabel} ID`]: entity, + Model: model, + "Spend ($)": formatNumberWithCommas(metrics.spend, 4), + "Requests": metrics.requests, + "Successful": metrics.successful, + "Failed": metrics.failed, + "Total Tokens": metrics.tokens, + }); + }); + }); + }); + + return dailyModelBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime()); +}; + +export const generateExportData = ( + spendData: EntitySpendData, + exportScope: ExportScope, + entityLabel: string, +): any[] => { + switch (exportScope) { + case "daily": + return generateDailyData(spendData, entityLabel); + case "daily_with_models": + return generateDailyWithModelsData(spendData, entityLabel); + default: + return generateDailyData(spendData, entityLabel); + } +}; + +export const generateMetadata = ( + entityType: "tag" | "team", + dateRange: { from?: Date; to?: Date }, + selectedFilters: string[], + exportScope: ExportScope, + spendData: EntitySpendData, +): ExportMetadata => ({ + export_date: new Date().toISOString(), + entity_type: entityType, + date_range: { + from: dateRange.from?.toISOString(), + to: dateRange.to?.toISOString(), + }, + filters_applied: selectedFilters.length > 0 ? selectedFilters : "None", + export_scope: exportScope, + summary: { + total_spend: spendData.metadata.total_spend, + total_requests: spendData.metadata.total_api_requests, + successful_requests: spendData.metadata.total_successful_requests, + failed_requests: spendData.metadata.total_failed_requests, + total_tokens: spendData.metadata.total_tokens, + }, +}); + diff --git a/ui/litellm-dashboard/src/components/entity_usage.tsx b/ui/litellm-dashboard/src/components/entity_usage.tsx index 3d2298040a2..22c5e5602b2 100644 --- a/ui/litellm-dashboard/src/components/entity_usage.tsx +++ b/ui/litellm-dashboard/src/components/entity_usage.tsx @@ -20,10 +20,7 @@ import { Tab, TabPanels, Subtitle, - Button, } from "@tremor/react"; -import AdvancedDatePicker from "./shared/advanced_date_picker"; -import { Select } from "antd"; import { ActivityMetrics, processActivityData } from "./activity_metrics"; import { DailyData, BreakdownMetrics, KeyMetricWithMetadata, EntityMetricWithMetadata, TagUsage } from "./usage/types"; import { tagDailyActivityCall, teamDailyActivityCall } from "./networking"; @@ -31,7 +28,7 @@ import TopKeyView from "./top_key_view"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { valueFormatterSpend } from "./usage/utils/value_formatters"; import { getProviderLogoAndName } from "./provider_info_helpers"; -import EntityUsageExportModal from "./entity_usage_export_modal"; +import { UsageExportHeader } from "./EntityUsageExport"; interface EntityMetrics { metrics: { @@ -105,7 +102,6 @@ const EntityUsage: React.FC = ({ from: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000), to: new Date(), }); - const [isExportModalOpen, setIsExportModalOpen] = useState(false); const fetchSpendData = async () => { if (!accessToken || !dateValue.from || !dateValue.to) return; @@ -332,44 +328,18 @@ const EntityUsage: React.FC = ({ return (
-
- - - - - {entityList && entityList.length > 0 && ( - - Filter by {entityType === "tag" ? "Tags" : "Teams"} - -
- -
- - -
-
- - ); -}; - -export default EntityUsageExportModal; - diff --git a/ui/litellm-dashboard/src/components/new_usage.tsx b/ui/litellm-dashboard/src/components/new_usage.tsx index 530728e070c..2e85bc2b157 100644 --- a/ui/litellm-dashboard/src/components/new_usage.tsx +++ b/ui/litellm-dashboard/src/components/new_usage.tsx @@ -28,7 +28,6 @@ import { TableCell, DateRangePickerValue, } from "@tremor/react"; -import AdvancedDatePicker from "./shared/advanced_date_picker"; import { userDailyActivityCall, userDailyActivityAggregatedCall, tagListCall } from "./networking"; import { Tag } from "./tag_management/types"; @@ -46,6 +45,9 @@ import { valueFormatterSpend } from "./usage/utils/value_formatters"; import CloudZeroExportModal from "./cloudzero_export_modal"; import { ChartLoader } from "./shared/chart_loader"; import { getProviderLogoAndName } from "./provider_info_helpers"; +import EntityUsageExportModal from "./EntityUsageExport"; +import AdvancedDatePicker from "./shared/advanced_date_picker"; +import { Button } from "@tremor/react"; interface NewUsagePageProps { accessToken: string | null; @@ -78,6 +80,7 @@ const NewUsagePage: React.FC = ({ accessToken, userRole, user const [allTags, setAllTags] = useState([]); const [modelViewType, setModelViewType] = useState<"groups" | "individual">("groups"); const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false); + const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); const getAllTags = async () => { if (!accessToken) { @@ -415,18 +418,35 @@ const NewUsagePage: React.FC = ({ accessToken, userRole, user {/* Your Usage Panel */} - + - - Cost - Model Activity - Key Activity - MCP Server Activity - +
+ + Cost + Model Activity + Key Activity + MCP Server Activity + + +
{/* Cost Panel */} @@ -743,6 +763,20 @@ const NewUsagePage: React.FC = ({ accessToken, userRole, user onClose={() => setIsCloudZeroModalOpen(false)} accessToken={accessToken} /> + + {/* Global Usage Export Modal */} + setIsGlobalExportModalOpen(false)} + entityType="team" + spendData={{ + results: userSpendData.results, + metadata: userSpendData.metadata, + }} + dateRange={dateValue} + selectedFilters={[]} + customTitle="Export Usage Data" + /> ); };