[Feat] Allow Team Admins to export a report of the team spending (#15542)

* v0 for export

* v0 for Export

* add types for EntityUsageExportModalProps

* add folder struct

* add summry selector

* add selector for export

* add utils for entity usage export

* refactored buttons

* fixes name

* fix alignment

* test: EntityUsageExportModal

* fix lint
This commit is contained in:
Ishaan Jaff 2025-10-14 15:07:59 -07:00 committed by GitHub
parent c86c6bc507
commit a10425d1ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 716 additions and 406 deletions

View file

@ -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(<EntityUsageExportModal {...baseProps} />);
// 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(<EntityUsageExportModal {...baseProps} />);
// 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();
});
});

View file

@ -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<EntityUsageExportModalProps> = ({
isOpen,
onClose,
entityType,
spendData,
dateRange,
selectedFilters,
customTitle,
}) => {
const [exportFormat, setExportFormat] = useState<ExportFormat>("csv");
const [exportScope, setExportScope] = useState<ExportScope>("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 (
<Modal
title={<span className="text-base font-semibold">{modalTitle}</span>}
open={isOpen}
onCancel={onClose}
footer={null}
width={480}
destroyOnClose
>
<div className="space-y-5 py-2">
<ExportSummary dateRange={dateRange} selectedFilters={selectedFilters} />
<ExportTypeSelector value={exportScope} onChange={setExportScope} entityType={entityType} />
<ExportFormatSelector value={exportFormat} onChange={setExportFormat} />
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Button variant="secondary" onClick={onClose} disabled={isExporting} size="sm">
Cancel
</Button>
<Button onClick={() => handleExport()} loading={isExporting} disabled={isExporting} size="sm">
{isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
</Button>
</div>
</div>
</Modal>
);
};
export default EntityUsageExportModal;

View file

@ -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<ExportFormatSelectorProps> = ({ value, onChange }) => {
return (
<div>
<label className="text-sm font-medium text-gray-700 block mb-2">Format</label>
<Select
value={value}
onChange={onChange}
className="w-full"
options={[
{
value: "csv",
label: "CSV (Excel, Google Sheets)",
},
{
value: "json",
label: "JSON (includes metadata)",
},
]}
/>
</div>
);
};
export default ExportFormatSelector;

View file

@ -0,0 +1,19 @@
import React from "react";
import type { DateRangePickerValue } from "@tremor/react";
interface ExportSummaryProps {
dateRange: DateRangePickerValue;
selectedFilters: string[];
}
const ExportSummary: React.FC<ExportSummaryProps> = ({ dateRange, selectedFilters }) => {
return (
<div className="text-sm text-gray-500">
{dateRange.from?.toLocaleDateString()} - {dateRange.to?.toLocaleDateString()}
{selectedFilters.length > 0 && ` · ${selectedFilters.length} filter${selectedFilters.length > 1 ? "s" : ""}`}
</div>
);
};
export default ExportSummary;

View file

@ -0,0 +1,39 @@
import React from "react";
import { Radio } from "antd";
import type { ExportScope } from "./types";
interface ExportTypeSelectorProps {
value: ExportScope;
onChange: (value: ExportScope) => void;
entityType: "tag" | "team";
}
const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({ value, onChange, entityType }) => {
return (
<div>
<label className="text-sm font-medium text-gray-700 block mb-2">Export type</label>
<Radio.Group value={value} onChange={(e) => onChange(e.target.value)} className="w-full">
<div className="space-y-2">
<label className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
<Radio value="daily" className="mt-0.5" />
<div className="ml-3 flex-1">
<div className="font-medium text-sm">Day-by-day breakdown</div>
<div className="text-xs text-gray-500 mt-0.5">Daily metrics for each {entityType}</div>
</div>
</label>
<label className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
<Radio value="daily_with_models" className="mt-0.5" />
<div className="ml-3 flex-1">
<div className="font-medium text-sm">Day-by-day by {entityType} and model</div>
<div className="text-xs text-gray-500 mt-0.5">Daily metrics split by model</div>
</div>
</label>
</div>
</Radio.Group>
</div>
);
};
export default ExportTypeSelector;

View file

@ -0,0 +1,107 @@
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
showFilters?: boolean;
filterLabel?: string;
filterPlaceholder?: string;
selectedFilters?: string[];
onFiltersChange?: (filters: string[]) => void;
filterOptions?: Array<{ label: string; value: string }>;
customTitle?: string;
compactLayout?: boolean;
}
const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
dateValue,
onDateChange,
entityType,
spendData,
showFilters = false,
filterLabel,
filterPlaceholder,
selectedFilters = [],
onFiltersChange,
filterOptions = [],
customTitle,
compactLayout = false,
}) => {
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
return (
<>
<div className="mb-4">
{/**
* Use CSS grid with items-end so all cells (date picker, 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.
*/}
<div
className={`grid ${
showFilters && filterOptions.length > 0 ? "grid-cols-[1fr_1fr_auto]" : "grid-cols-[1fr_auto]"
} items-end gap-4`}
>
<div>
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
</div>
{showFilters && filterOptions.length > 0 && (
<div>
{filterLabel && <Text className="mb-2">{filterLabel}</Text>}
<Select
mode="multiple"
style={{ width: "100%" }}
placeholder={filterPlaceholder}
value={selectedFilters}
onChange={onFiltersChange}
options={filterOptions}
allowClear
/>
</div>
)}
<div className="justify-self-end">
<Button
onClick={() => setIsExportModalOpen(true)}
icon={() => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
)}
>
Export Data
</Button>
</div>
</div>
</div>
<EntityUsageExportModal
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
entityType={entityType}
spendData={spendData}
dateRange={dateValue}
selectedFilters={selectedFilters}
customTitle={customTitle}
/>
</>
);
};
export default UsageExportHeader;

View file

@ -0,0 +1,4 @@
export { default } from "./EntityUsageExportModal";
export { default as UsageExportHeader } from "./UsageExportHeader";
export * from "./types";

View file

@ -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;
};
}

View file

@ -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,
},
});

View file

@ -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<EntityUsageProps> = ({
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<EntityUsageProps> = ({
return (
<div style={{ width: "100%" }} className="relative">
<div className="flex justify-between items-start mb-4">
<Grid numItems={2} className="gap-2 flex-1">
<Col>
<AdvancedDatePicker value={dateValue} onValueChange={setDateValue} />
</Col>
{entityList && entityList.length > 0 && (
<Col>
<Text>Filter by {entityType === "tag" ? "Tags" : "Teams"}</Text>
<Select
mode="multiple"
style={{ width: "100%" }}
placeholder={`Select ${entityType === "tag" ? "tags" : "teams"} to filter...`}
value={selectedTags}
onChange={setSelectedTags}
options={getAllTags()}
className="mt-2"
allowClear
/>
</Col>
)}
</Grid>
<Button
onClick={() => setIsExportModalOpen(true)}
className="ml-4"
icon={() => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
)}
>
Export Data
</Button>
</div>
<UsageExportHeader
dateValue={dateValue}
onDateChange={setDateValue}
entityType={entityType}
spendData={spendData}
showFilters={entityList !== null && entityList.length > 0}
filterLabel={`Filter by ${entityType === "tag" ? "Tags" : "Teams"}`}
filterPlaceholder={`Select ${entityType === "tag" ? "tags" : "teams"} to filter...`}
selectedFilters={selectedTags}
onFiltersChange={setSelectedTags}
filterOptions={getAllTags() || undefined}
/>
<TabGroup>
<TabList variant="solid" className="mt-1">
<Tab>Cost</Tab>
@ -674,15 +644,6 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
</TabPanel>
</TabPanels>
</TabGroup>
<EntityUsageExportModal
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
entityType={entityType}
spendData={spendData}
dateRange={dateValue}
selectedFilters={selectedTags}
/>
</div>
);
};

View file

@ -1,346 +0,0 @@
import React, { useState } from "react";
import { Text, Button } from "@tremor/react";
import { Modal, Radio, Select } from "antd";
import Papa from "papaparse";
import NotificationsManager from "./molecules/notifications_manager";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import type { DateRangePickerValue } from "@tremor/react";
interface EntitySpendData {
results: any[];
metadata: {
total_spend: number;
total_api_requests: number;
total_successful_requests: number;
total_failed_requests: number;
total_tokens: number;
};
}
interface EntityUsageExportModalProps {
isOpen: boolean;
onClose: () => void;
entityType: "tag" | "team";
spendData: EntitySpendData;
dateRange: DateRangePickerValue;
selectedFilters: string[];
}
type ExportFormat = "csv" | "json";
type ExportScope = "daily" | "daily_with_models";
const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
isOpen,
onClose,
entityType,
spendData,
dateRange,
selectedFilters,
}) => {
const [exportFormat, setExportFormat] = useState<ExportFormat>("csv");
const [exportScope, setExportScope] = useState<ExportScope>("daily");
const [isExporting, setIsExporting] = useState(false);
const entityLabel = entityType === "tag" ? "Tag" : "Team";
const entityLabelPlural = entityType === "tag" ? "Tags" : "Teams";
const getEntityBreakdown = () => {
const entitySpend: { [key: string]: any } = {};
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: any, b: any) => b.metrics.spend - a.metrics.spend);
};
const generateDailyData = () => {
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());
};
const generateDailyWithModelsData = () => {
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());
};
const generateExportData = () => {
switch (exportScope) {
case "daily":
return generateDailyData();
case "daily_with_models":
return generateDailyWithModelsData();
default:
return generateDailyData();
}
};
const generateMetadata = () => ({
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,
},
});
const handleExportCSV = () => {
const data = generateExportData();
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();
const metadata = generateMetadata();
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);
}
};
const getScopeDescription = () => {
switch (exportScope) {
case "daily":
return `Day-by-day breakdown for each ${entityType}`;
case "daily_with_models":
return `Day-by-day breakdown by ${entityType} and model`;
default:
return "";
}
};
const getRecordCount = () => {
const data = generateExportData();
return data.length;
};
return (
<Modal
title={<span className="text-base font-semibold">Export {entityLabel} Usage</span>}
open={isOpen}
onCancel={onClose}
footer={null}
width={480}
destroyOnClose
>
<div className="space-y-5 py-2">
<div className="text-sm text-gray-500">
{dateRange.from?.toLocaleDateString()} - {dateRange.to?.toLocaleDateString()}
{selectedFilters.length > 0 && ` · ${selectedFilters.length} filter${selectedFilters.length > 1 ? "s" : ""}`}
</div>
<div>
<label className="text-sm font-medium text-gray-700 block mb-2">Export type</label>
<Radio.Group
value={exportScope}
onChange={(e) => setExportScope(e.target.value)}
className="w-full"
>
<div className="space-y-2">
<label className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
<Radio value="daily" className="mt-0.5" />
<div className="ml-3 flex-1">
<div className="font-medium text-sm">Day-by-day breakdown</div>
<div className="text-xs text-gray-500 mt-0.5">Daily metrics for each {entityType}</div>
</div>
</label>
<label className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
<Radio value="daily_with_models" className="mt-0.5" />
<div className="ml-3 flex-1">
<div className="font-medium text-sm">Day-by-day by {entityType} and model</div>
<div className="text-xs text-gray-500 mt-0.5">Daily metrics split by model</div>
</div>
</label>
</div>
</Radio.Group>
</div>
<div>
<label className="text-sm font-medium text-gray-700 block mb-2">Format</label>
<Select
value={exportFormat}
onChange={setExportFormat}
className="w-full"
options={[
{
value: "csv",
label: "CSV (Excel, Google Sheets)",
},
{
value: "json",
label: "JSON (includes metadata)",
},
]}
/>
</div>
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Button variant="secondary" onClick={onClose} disabled={isExporting} size="sm">
Cancel
</Button>
<Button
onClick={() => handleExport()}
loading={isExporting}
disabled={isExporting}
size="sm"
>
{isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
</Button>
</div>
</div>
</Modal>
);
};
export default EntityUsageExportModal;

View file

@ -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<NewUsagePageProps> = ({ accessToken, userRole, user
const [allTags, setAllTags] = useState<EntityList[]>([]);
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<NewUsagePageProps> = ({ accessToken, userRole, user
<TabPanels>
{/* Your Usage Panel */}
<TabPanel>
<Grid numItems={2} className="gap-10 w-1/2 mb-4">
<Grid numItems={2} className="gap-10 w-full mb-4">
<Col>
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</Col>
</Grid>
<TabGroup>
<TabList variant="solid" className="mt-1">
<Tab>Cost</Tab>
<Tab>Model Activity</Tab>
<Tab>Key Activity</Tab>
<Tab>MCP Server Activity</Tab>
</TabList>
<div className="flex justify-between items-center">
<TabList variant="solid" className="mt-1">
<Tab>Cost</Tab>
<Tab>Model Activity</Tab>
<Tab>Key Activity</Tab>
<Tab>MCP Server Activity</Tab>
</TabList>
<Button
onClick={() => setIsGlobalExportModalOpen(true)}
icon={() => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
)}
>
Export Data
</Button>
</div>
<TabPanels>
{/* Cost Panel */}
<TabPanel>
@ -743,6 +763,20 @@ const NewUsagePage: React.FC<NewUsagePageProps> = ({ accessToken, userRole, user
onClose={() => setIsCloudZeroModalOpen(false)}
accessToken={accessToken}
/>
{/* Global Usage Export Modal */}
<EntityUsageExportModal
isOpen={isGlobalExportModalOpen}
onClose={() => setIsGlobalExportModalOpen(false)}
entityType="team"
spendData={{
results: userSpendData.results,
metadata: userSpendData.metadata,
}}
dateRange={dateValue}
selectedFilters={[]}
customTitle="Export Usage Data"
/>
</div>
);
};