mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
[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:
parent
c86c6bc507
commit
a10425d1ce
12 changed files with 716 additions and 406 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { default } from "./EntityUsageExportModal";
|
||||
export { default as UsageExportHeader } from "./UsageExportHeader";
|
||||
export * from "./types";
|
||||
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
162
ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts
Normal file
162
ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue