Change usage page to have a parent date picker (#16264)

This commit is contained in:
yuneng-jiang
2025-11-07 10:00:37 -08:00
committed by GitHub
parent 20d1bed514
commit 0a90283fec
9 changed files with 1233 additions and 396 deletions
@@ -1,14 +1,12 @@
import React, { useState } from "react";
import { Button, Text } from "@tremor/react";
import { Select } from "antd";
import AdvancedDatePicker from "../shared/advanced_date_picker";
import EntityUsageExportModal from "./EntityUsageExportModal";
import type { DateRangePickerValue } from "@tremor/react";
import type { EntitySpendData } from "./types";
interface UsageExportHeaderProps {
dateValue: DateRangePickerValue;
onDateChange: (value: DateRangePickerValue) => void;
entityType: "tag" | "team";
spendData: EntitySpendData;
// Optional filter props
@@ -24,7 +22,6 @@ interface UsageExportHeaderProps {
const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
dateValue,
onDateChange,
entityType,
spendData,
showFilters = false,
@@ -38,23 +35,23 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
}) => {
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
// Determine grid layout based on what's visible
const getGridCols = () => {
const hasFilters = showFilters && filterOptions.length > 0;
if (hasFilters) return "grid-cols-[1fr_auto]";
return "grid-cols-[auto]";
};
return (
<>
<div className="mb-4">
{/**
* Use CSS grid with items-end so all cells (date picker, filter, button)
* Use CSS grid with items-end so all cells (filter, button)
* align to the same baseline regardless of label heights. This removes
* vertical drift when the right column has a label above the input.
*/}
<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>
<div className={`grid ${getGridCols()} items-end gap-4`}>
{showFilters && filterOptions.length > 0 && (
<div>
{filterLabel && <Text className="mb-2">{filterLabel}</Text>}
@@ -104,4 +101,3 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
};
export default UsageExportHeader;
@@ -0,0 +1,215 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import EntityUsage from "./entity_usage";
import * as networking from "./networking";
// Polyfill ResizeObserver for test environment
beforeAll(() => {
if (typeof window !== "undefined" && !window.ResizeObserver) {
window.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
} as any;
}
});
// Mock the networking module
vi.mock("./networking", () => ({
tagDailyActivityCall: vi.fn(),
teamDailyActivityCall: vi.fn(),
}));
// Mock the child components to simplify testing
vi.mock("./activity_metrics", () => ({
ActivityMetrics: () => <div>Activity Metrics</div>,
processActivityData: () => ({ data: [], metadata: {} }),
}));
vi.mock("./top_key_view", () => ({
default: () => <div>Top Keys</div>,
}));
vi.mock("./top_model_view", () => ({
default: () => <div>Top Models</div>,
}));
vi.mock("./EntityUsageExport", () => ({
UsageExportHeader: () => <div>Usage Export Header</div>,
}));
describe("EntityUsage", () => {
const mockTagDailyActivityCall = vi.mocked(networking.tagDailyActivityCall);
const mockTeamDailyActivityCall = vi.mocked(networking.teamDailyActivityCall);
const mockSpendData = {
results: [
{
date: "2025-01-01",
metrics: {
spend: 100.5,
api_requests: 1000,
successful_requests: 950,
failed_requests: 50,
total_tokens: 50000,
prompt_tokens: 30000,
completion_tokens: 20000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
breakdown: {
entities: {
"tag-1": {
metrics: {
spend: 60.3,
api_requests: 600,
successful_requests: 570,
failed_requests: 30,
total_tokens: 30000,
prompt_tokens: 18000,
completion_tokens: 12000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {
team_alias: "Tag 1",
},
api_key_breakdown: {},
},
},
models: {},
api_keys: {},
providers: {
openai: {
metrics: {
spend: 100.5,
api_requests: 1000,
successful_requests: 950,
failed_requests: 50,
total_tokens: 50000,
prompt_tokens: 30000,
completion_tokens: 20000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
},
},
],
metadata: {
total_spend: 100.5,
total_api_requests: 1000,
total_successful_requests: 950,
total_failed_requests: 50,
total_tokens: 50000,
},
};
const defaultProps = {
accessToken: "test-token",
entityType: "tag" as const,
entityId: "test-tag",
userID: "user-123",
userRole: "Admin",
entityList: [
{ label: "Tag 1", value: "tag-1" },
{ label: "Tag 2", value: "tag-2" },
],
premiumUser: true,
dateValue: {
from: new Date("2025-01-01"),
to: new Date("2025-01-31"),
},
};
beforeEach(() => {
mockTagDailyActivityCall.mockClear();
mockTeamDailyActivityCall.mockClear();
mockTagDailyActivityCall.mockResolvedValue(mockSpendData);
mockTeamDailyActivityCall.mockResolvedValue(mockSpendData);
});
it("should render with tag entity type and display spend metrics", async () => {
render(<EntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
});
// Check that spend metrics are displayed
expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument();
expect(screen.getByText("Total Spend")).toBeInTheDocument();
// Use getAllByText since $100.50 appears in multiple places
const spendElements = screen.getAllByText("$100.50");
expect(spendElements.length).toBeGreaterThan(0);
expect(screen.getByText("1,000")).toBeInTheDocument(); // Total Requests
});
it("should render with team entity type and call team API", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
// Check that it shows team-specific label
expect(screen.getByText("Team Spend Overview")).toBeInTheDocument();
// Use getAllByText since $100.50 appears in multiple places
const spendElements = screen.getAllByText("$100.50");
expect(spendElements.length).toBeGreaterThan(0);
});
it("should switch between tabs", async () => {
render(<EntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
});
// Check default tab (Cost) is shown
expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument();
// Click Model Activity tab
const modelActivityTab = screen.getByText("Model Activity");
fireEvent.click(modelActivityTab);
// Should show activity metrics
expect(screen.getAllByText("Activity Metrics")[0]).toBeInTheDocument();
// Click Key Activity tab
const keyActivityTab = screen.getByText("Key Activity");
fireEvent.click(keyActivityTab);
// Should show activity metrics again
expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument();
});
it("should handle empty data gracefully", async () => {
const emptyData = {
results: [],
metadata: {
total_spend: 0,
total_api_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,
total_tokens: 0,
},
};
mockTagDailyActivityCall.mockResolvedValue(emptyData);
render(<EntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
});
// Check that zero values are displayed (component formats it as $0.00)
expect(screen.getByText("$0.00")).toBeInTheDocument();
expect(screen.getByText("Total Spend")).toBeInTheDocument();
});
});
@@ -74,6 +74,7 @@ interface EntityUsageProps {
userRole: string | null;
entityList: EntityList[] | null;
premiumUser: boolean;
dateValue: DateRangePickerValue;
}
const EntityUsage: React.FC<EntityUsageProps> = ({
@@ -84,6 +85,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
userRole,
entityList,
premiumUser,
dateValue,
}) => {
const [spendData, setSpendData] = useState<EntitySpendData>({
results: [],
@@ -99,10 +101,6 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
const modelMetrics = processActivityData(spendData, "models");
const keyMetrics = processActivityData(spendData, "api_keys");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [dateValue, setDateValue] = useState<DateRangePickerValue>({
from: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000),
to: new Date(),
});
const fetchSpendData = async () => {
if (!accessToken || !dateValue.from || !dateValue.to) return;
@@ -331,7 +329,6 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
<div style={{ width: "100%" }} className="relative">
<UsageExportHeader
dateValue={dateValue}
onDateChange={setDateValue}
entityType={entityType}
spendData={spendData}
showFilters={entityList !== null && entityList.length > 0}
@@ -0,0 +1,253 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import NewUsagePage from "./new_usage";
import * as networking from "./networking";
// Polyfill ResizeObserver for test environment
beforeAll(() => {
if (typeof window !== "undefined" && !window.ResizeObserver) {
window.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
} as any;
}
});
// Mock the networking module
vi.mock("./networking", () => ({
userDailyActivityCall: vi.fn(),
userDailyActivityAggregatedCall: vi.fn(),
tagListCall: vi.fn(),
}));
// Mock child components to simplify testing
vi.mock("./activity_metrics", () => ({
ActivityMetrics: () => <div>Activity Metrics</div>,
processActivityData: () => ({ data: [], metadata: {} }),
}));
vi.mock("./view_user_spend", () => ({
default: () => <div>View User Spend</div>,
}));
vi.mock("./top_key_view", () => ({
default: () => <div>Top Keys</div>,
}));
vi.mock("./entity_usage", () => ({
default: () => <div>Entity Usage</div>,
EntityList: [],
}));
vi.mock("./user_agent_activity", () => ({
default: () => <div>User Agent Activity</div>,
}));
vi.mock("./cloudzero_export_modal", () => ({
default: () => <div>CloudZero Export Modal</div>,
}));
vi.mock("./EntityUsageExport", () => ({
default: () => <div>Entity Usage Export Modal</div>,
}));
describe("NewUsage", () => {
const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall);
const mockTagListCall = vi.mocked(networking.tagListCall);
const mockSpendData = {
results: [
{
date: "2025-01-01",
metrics: {
spend: 125.75,
api_requests: 1500,
successful_requests: 1450,
failed_requests: 50,
total_tokens: 75000,
prompt_tokens: 45000,
completion_tokens: 30000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
breakdown: {
models: {
"gpt-4": {
metrics: {
spend: 75.5,
api_requests: 800,
successful_requests: 780,
failed_requests: 20,
total_tokens: 40000,
prompt_tokens: 24000,
completion_tokens: 16000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {},
api_key_breakdown: {},
},
},
model_groups: {
"gpt-4": {
metrics: {
spend: 75.5,
api_requests: 800,
successful_requests: 780,
failed_requests: 20,
total_tokens: 40000,
prompt_tokens: 24000,
completion_tokens: 16000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {},
api_key_breakdown: {},
},
},
api_keys: {
"sk-test123": {
metrics: {
spend: 125.75,
api_requests: 1500,
successful_requests: 1450,
failed_requests: 50,
total_tokens: 75000,
prompt_tokens: 45000,
completion_tokens: 30000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {
key_alias: "Test Key",
tags: ["production"],
},
},
},
providers: {
openai: {
metrics: {
spend: 125.75,
api_requests: 1500,
successful_requests: 1450,
failed_requests: 50,
total_tokens: 75000,
prompt_tokens: 45000,
completion_tokens: 30000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
mcp_servers: {},
},
},
],
metadata: {
total_spend: 125.75,
total_api_requests: 1500,
total_successful_requests: 1450,
total_failed_requests: 50,
total_tokens: 75000,
},
};
const defaultProps = {
accessToken: "test-token",
userRole: "Admin",
userID: "user-123",
teams: [
{
team_id: "team-1",
team_alias: "Test Team",
models: [],
max_budget: null,
spend: 0,
tpm_limit: null,
rpm_limit: null,
blocked: false,
metadata: {},
budget_duration: null,
organization_id: "org-123",
created_at: "2025-01-01T00:00:00Z",
keys: [],
members_with_roles: [],
},
],
premiumUser: true,
};
beforeEach(() => {
mockUserDailyActivityAggregatedCall.mockClear();
mockTagListCall.mockClear();
mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
mockTagListCall.mockResolvedValue({});
});
it("should render and fetch usage data on mount", async () => {
render(<NewUsagePage {...defaultProps} />);
// Wait for data to be fetched
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Check that key metrics are displayed
expect(screen.getByText("Total Requests")).toBeInTheDocument();
expect(screen.getByText("1,500")).toBeInTheDocument();
expect(screen.getByText("Successful Requests")).toBeInTheDocument();
// Use getAllByText since this value appears in multiple places (metrics card + table)
const successfulRequestElements = screen.getAllByText("1,450");
expect(successfulRequestElements.length).toBeGreaterThan(0);
});
it("should display usage metrics and charts", async () => {
render(<NewUsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Check for usage metrics cards
expect(screen.getByText("Total Requests")).toBeInTheDocument();
expect(screen.getByText("Successful Requests")).toBeInTheDocument();
expect(screen.getByText("Failed Requests")).toBeInTheDocument();
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
// Check for chart titles
expect(screen.getByText("Daily Spend")).toBeInTheDocument();
expect(screen.getByText("Top API Keys")).toBeInTheDocument();
});
it("should switch between tabs correctly", async () => {
render(<NewUsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Default tab should show Global Usage (for admin)
expect(screen.getByText("Daily Spend")).toBeInTheDocument();
// Switch to Team Usage tab
const teamUsageTab = screen.getByText("Team Usage");
fireEvent.click(teamUsageTab);
// Should render EntityUsage component
await waitFor(() => {
const entityUsageElements = screen.getAllByText("Entity Usage");
expect(entityUsageElements.length).toBeGreaterThan(0);
});
// Switch to Tag Usage tab (admin only)
const tagUsageTab = screen.getByText("Tag Usage");
fireEvent.click(tagUsageTab);
// Should still render EntityUsage component for tags
await waitFor(() => {
const entityUsageElements = screen.getAllByText("Entity Usage");
expect(entityUsageElements.length).toBeGreaterThan(0);
});
});
});
+356 -337
View File
@@ -408,354 +408,373 @@ const NewUsagePage: React.FC<NewUsagePageProps> = ({ accessToken, userRole, user
</div>
)} */}
<TabGroup>
<TabList variant="solid" className="mt-1">
{all_admin_roles.includes(userRole || "") ? <Tab>Global Usage</Tab> : <Tab>Your Usage</Tab>}
<Tab>Team Usage</Tab>
{all_admin_roles.includes(userRole || "") ? <Tab>Tag Usage</Tab> : <></>}
{all_admin_roles.includes(userRole || "") ? <Tab>User Agent Activity</Tab> : <></>}
</TabList>
<TabPanels>
{/* Your Usage Panel */}
<TabPanel>
<Grid numItems={2} className="gap-10 w-full mb-4">
<Col>
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</Col>
</Grid>
<TabGroup>
<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>
<Grid numItems={2} className="gap-2 w-full">
{/* Total Spend Card */}
<Col numColSpan={2}>
<Text className="text-tremor-default text-tremor-content dark:text-dark-tremor-content mb-2 mt-2 text-lg">
Project Spend{" "}
{new Date().toLocaleString("default", {
month: "long",
})}{" "}
1 - {new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate()}
</Text>
<ViewUserSpend
userID={userID}
userRole={userRole}
accessToken={accessToken}
userSpend={totalSpend}
selectedTeam={null}
userMaxBudget={null}
/>
</Col>
<Col numColSpan={2}>
<Card>
<Title>Usage Metrics</Title>
<Grid numItems={5} className="gap-4 mt-4">
<Card>
<Title>Total Requests</Title>
<Text className="text-2xl font-bold mt-2">
{userSpendData.metadata?.total_api_requests?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<Title>Successful Requests</Title>
<Text className="text-2xl font-bold mt-2 text-green-600">
{userSpendData.metadata?.total_successful_requests?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<Title>Failed Requests</Title>
<Text className="text-2xl font-bold mt-2 text-red-600">
{userSpendData.metadata?.total_failed_requests?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<Title>Total Tokens</Title>
<Text className="text-2xl font-bold mt-2">
{userSpendData.metadata?.total_tokens?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<Title>Average Cost per Request</Title>
<Text className="text-2xl font-bold mt-2">
$
{formatNumberWithCommas(
(totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1),
4,
)}
</Text>
</Card>
</Grid>
</Card>
</Col>
{/* Daily Spend Chart */}
<Col numColSpan={2}>
<Card>
<Title>Daily Spend</Title>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<BarChart
data={[...userSpendData.results].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
{/* Global Date Picker and Tabs - Single Row */}
<div className="flex items-end justify-between gap-6 mb-6">
<div className="flex-1">
<TabGroup>
<div className="flex items-end justify-start gap-6 mb-6">
<TabList variant="solid">
{all_admin_roles.includes(userRole || "") ? <Tab>Global Usage</Tab> : <Tab>Your Usage</Tab>}
<Tab>Team Usage</Tab>
{all_admin_roles.includes(userRole || "") ? <Tab>Tag Usage</Tab> : <></>}
{all_admin_roles.includes(userRole || "") ? <Tab>User Agent Activity</Tab> : <></>}
</TabList>
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</div>
<TabPanels>
{/* Your Usage Panel */}
<TabPanel>
<TabGroup>
<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>
<Grid numItems={2} className="gap-2 w-full">
{/* Total Spend Card */}
<Col numColSpan={2}>
<Text className="text-tremor-default text-tremor-content dark:text-dark-tremor-content mb-2 mt-2 text-lg">
Project Spend{" "}
{dateValue.from && dateValue.to && (
<>
{dateValue.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year:
dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined,
})}
{" - "}
{dateValue.to.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</>
)}
index="date"
categories={["metrics.spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
yAxisWidth={100}
showLegend={false}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.date}</p>
<p className="text-cyan-500">
Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}
</p>
<p className="text-gray-600">Requests: {data.metrics.api_requests}</p>
<p className="text-gray-600">Successful: {data.metrics.successful_requests}</p>
<p className="text-gray-600">Failed: {data.metrics.failed_requests}</p>
<p className="text-gray-600">Tokens: {data.metrics.total_tokens}</p>
</div>
);
}}
/>
)}
</Card>
</Col>
{/* Top API Keys */}
<Col numColSpan={1}>
<Card className="h-full">
<Title>Top API Keys</Title>
<TopKeyView
topKeys={getTopKeys()}
accessToken={accessToken}
userID={userID}
userRole={userRole}
teams={null}
premiumUser={premiumUser}
/>
</Card>
</Col>
</Text>
{/* Top Models */}
<Col numColSpan={1}>
<Card className="h-full">
<div className="flex justify-between items-center mb-4">
<Title>{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}</Title>
<div className="flex bg-gray-100 rounded-lg p-1">
<button
className={`px-3 py-1 text-sm rounded-md transition-colors ${
modelViewType === "groups"
? "bg-white shadow-sm text-gray-900"
: "text-gray-600 hover:text-gray-900"
}`}
onClick={() => setModelViewType("groups")}
>
Public Model Name
</button>
<button
className={`px-3 py-1 text-sm rounded-md transition-colors ${
modelViewType === "individual"
? "bg-white shadow-sm text-gray-900"
: "text-gray-600 hover:text-gray-900"
}`}
onClick={() => setModelViewType("individual")}
>
Litellm Model Name
</button>
</div>
</div>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<BarChart
className="mt-4 h-40"
data={modelViewType === "groups" ? getTopModelGroups() : getTopModels()}
index="key"
categories={["spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
layout="vertical"
yAxisWidth={200}
showLegend={false}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.key}</p>
<p className="text-cyan-500">Spend: ${formatNumberWithCommas(data.spend, 2)}</p>
<p className="text-gray-600">Total Requests: {data.requests.toLocaleString()}</p>
<p className="text-green-600">
Successful: {data.successful_requests.toLocaleString()}
</p>
<p className="text-red-600">Failed: {data.failed_requests.toLocaleString()}</p>
<p className="text-gray-600">Tokens: {data.tokens.toLocaleString()}</p>
</div>
);
}}
<ViewUserSpend
userID={userID}
userRole={userRole}
accessToken={accessToken}
userSpend={totalSpend}
selectedTeam={null}
userMaxBudget={null}
/>
)}
</Card>
</Col>
</Col>
{/* Spend by Provider */}
<Col numColSpan={2}>
<Card className="h-full">
<div className="flex justify-between items-center mb-4">
<Title>Spend by Provider</Title>
</div>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<Grid numItems={2}>
<Col numColSpan={1}>
<DonutChart
className="mt-4 h-40"
data={getProviderSpend()}
index="provider"
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
<Col numColSpan={2}>
<Card>
<Title>Usage Metrics</Title>
<Grid numItems={5} className="gap-4 mt-4">
<Card>
<Title>Total Requests</Title>
<Text className="text-2xl font-bold mt-2">
{userSpendData.metadata?.total_api_requests?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<Title>Successful Requests</Title>
<Text className="text-2xl font-bold mt-2 text-green-600">
{userSpendData.metadata?.total_successful_requests?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<Title>Failed Requests</Title>
<Text className="text-2xl font-bold mt-2 text-red-600">
{userSpendData.metadata?.total_failed_requests?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<Title>Total Tokens</Title>
<Text className="text-2xl font-bold mt-2">
{userSpendData.metadata?.total_tokens?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<Title>Average Cost per Request</Title>
<Text className="text-2xl font-bold mt-2">
$
{formatNumberWithCommas(
(totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1),
4,
)}
</Text>
</Card>
</Grid>
</Card>
</Col>
{/* Daily Spend Chart */}
<Col numColSpan={2}>
<Card>
<Title>Daily Spend</Title>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<BarChart
data={[...userSpendData.results].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
)}
index="date"
categories={["metrics.spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
yAxisWidth={100}
showLegend={false}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.date}</p>
<p className="text-cyan-500">
Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}
</p>
<p className="text-gray-600">Requests: {data.metrics.api_requests}</p>
<p className="text-gray-600">Successful: {data.metrics.successful_requests}</p>
<p className="text-gray-600">Failed: {data.metrics.failed_requests}</p>
<p className="text-gray-600">Tokens: {data.metrics.total_tokens}</p>
</div>
);
}}
/>
</Col>
<Col numColSpan={1}>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Provider</TableHeaderCell>
<TableHeaderCell>Spend</TableHeaderCell>
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
<TableHeaderCell>Tokens</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{getProviderSpend()
.filter((provider) => provider.spend > 0)
.map((provider) => (
<TableRow key={provider.provider}>
<TableCell>
<div className="flex items-center space-x-2">
{provider.provider && (
<img
src={getProviderLogoAndName(provider.provider).logo}
alt={`${provider.provider} logo`}
className="w-4 h-4"
onError={(e) => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
const fallbackDiv = document.createElement("div");
fallbackDiv.className =
"w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs";
fallbackDiv.textContent = provider.provider?.charAt(0) || "-";
parent.replaceChild(fallbackDiv, target);
}
}}
/>
)}
<span>{provider.provider}</span>
</div>
</TableCell>
<TableCell>${formatNumberWithCommas(provider.spend, 2)}</TableCell>
<TableCell className="text-green-600">
{provider.successful_requests.toLocaleString()}
</TableCell>
<TableCell className="text-red-600">
{provider.failed_requests.toLocaleString()}
</TableCell>
<TableCell>{provider.tokens.toLocaleString()}</TableCell>
)}
</Card>
</Col>
{/* Top API Keys */}
<Col numColSpan={1}>
<Card className="h-full">
<Title>Top API Keys</Title>
<TopKeyView
topKeys={getTopKeys()}
accessToken={accessToken}
userID={userID}
userRole={userRole}
teams={null}
premiumUser={premiumUser}
/>
</Card>
</Col>
{/* Top Models */}
<Col numColSpan={1}>
<Card className="h-full">
<div className="flex justify-between items-center mb-4">
<Title>
{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}
</Title>
<div className="flex bg-gray-100 rounded-lg p-1">
<button
className={`px-3 py-1 text-sm rounded-md transition-colors ${
modelViewType === "groups"
? "bg-white shadow-sm text-gray-900"
: "text-gray-600 hover:text-gray-900"
}`}
onClick={() => setModelViewType("groups")}
>
Public Model Name
</button>
<button
className={`px-3 py-1 text-sm rounded-md transition-colors ${
modelViewType === "individual"
? "bg-white shadow-sm text-gray-900"
: "text-gray-600 hover:text-gray-900"
}`}
onClick={() => setModelViewType("individual")}
>
Litellm Model Name
</button>
</div>
</div>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<BarChart
className="mt-4 h-40"
data={modelViewType === "groups" ? getTopModelGroups() : getTopModels()}
index="key"
categories={["spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
layout="vertical"
yAxisWidth={200}
showLegend={false}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.key}</p>
<p className="text-cyan-500">Spend: ${formatNumberWithCommas(data.spend, 2)}</p>
<p className="text-gray-600">Total Requests: {data.requests.toLocaleString()}</p>
<p className="text-green-600">
Successful: {data.successful_requests.toLocaleString()}
</p>
<p className="text-red-600">Failed: {data.failed_requests.toLocaleString()}</p>
<p className="text-gray-600">Tokens: {data.tokens.toLocaleString()}</p>
</div>
);
}}
/>
)}
</Card>
</Col>
{/* Spend by Provider */}
<Col numColSpan={2}>
<Card className="h-full">
<div className="flex justify-between items-center mb-4">
<Title>Spend by Provider</Title>
</div>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<Grid numItems={2}>
<Col numColSpan={1}>
<DonutChart
className="mt-4 h-40"
data={getProviderSpend()}
index="provider"
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
colors={["cyan"]}
/>
</Col>
<Col numColSpan={1}>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Provider</TableHeaderCell>
<TableHeaderCell>Spend</TableHeaderCell>
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
<TableHeaderCell>Tokens</TableHeaderCell>
</TableRow>
))}
</TableBody>
</Table>
</Col>
</Grid>
)}
</Card>
</Col>
</TableHead>
<TableBody>
{getProviderSpend()
.filter((provider) => provider.spend > 0)
.map((provider) => (
<TableRow key={provider.provider}>
<TableCell>
<div className="flex items-center space-x-2">
{provider.provider && (
<img
src={getProviderLogoAndName(provider.provider).logo}
alt={`${provider.provider} logo`}
className="w-4 h-4"
onError={(e) => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
const fallbackDiv = document.createElement("div");
fallbackDiv.className =
"w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs";
fallbackDiv.textContent = provider.provider?.charAt(0) || "-";
parent.replaceChild(fallbackDiv, target);
}
}}
/>
)}
<span>{provider.provider}</span>
</div>
</TableCell>
<TableCell>${formatNumberWithCommas(provider.spend, 2)}</TableCell>
<TableCell className="text-green-600">
{provider.successful_requests.toLocaleString()}
</TableCell>
<TableCell className="text-red-600">
{provider.failed_requests.toLocaleString()}
</TableCell>
<TableCell>{provider.tokens.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Col>
</Grid>
)}
</Card>
</Col>
{/* Usage Metrics */}
</Grid>
</TabPanel>
{/* Usage Metrics */}
</Grid>
</TabPanel>
{/* Activity Panel */}
<TabPanel>
<ActivityMetrics modelMetrics={modelMetrics} />
</TabPanel>
<TabPanel>
<ActivityMetrics modelMetrics={keyMetrics} />
</TabPanel>
<TabPanel>
<ActivityMetrics modelMetrics={mcpServerMetrics} />
</TabPanel>
</TabPanels>
</TabGroup>
</TabPanel>
{/* Activity Panel */}
<TabPanel>
<ActivityMetrics modelMetrics={modelMetrics} />
</TabPanel>
<TabPanel>
<ActivityMetrics modelMetrics={keyMetrics} />
</TabPanel>
<TabPanel>
<ActivityMetrics modelMetrics={mcpServerMetrics} />
</TabPanel>
</TabPanels>
</TabGroup>
</TabPanel>
{/* Team Usage Panel */}
<TabPanel>
<EntityUsage
accessToken={accessToken}
entityType="team"
userID={userID}
userRole={userRole}
entityList={
teams?.map((team) => ({
label: team.team_alias,
value: team.team_id,
})) || null
}
premiumUser={premiumUser}
/>
</TabPanel>
{/* Team Usage Panel */}
<TabPanel>
<EntityUsage
accessToken={accessToken}
entityType="team"
userID={userID}
userRole={userRole}
entityList={
teams?.map((team) => ({
label: team.team_alias,
value: team.team_id,
})) || null
}
premiumUser={premiumUser}
dateValue={dateValue}
/>
</TabPanel>
{/* Tag Usage Panel */}
<TabPanel>
<EntityUsage
accessToken={accessToken}
entityType="tag"
userID={userID}
userRole={userRole}
entityList={allTags}
premiumUser={premiumUser}
/>
</TabPanel>
{/* User Agent Activity Panel */}
<TabPanel>
<UserAgentActivity accessToken={accessToken} userRole={userRole} />
</TabPanel>
</TabPanels>
</TabGroup>
{/* Tag Usage Panel */}
<TabPanel>
<EntityUsage
accessToken={accessToken}
entityType="tag"
userID={userID}
userRole={userRole}
entityList={allTags}
premiumUser={premiumUser}
dateValue={dateValue}
/>
</TabPanel>
{/* User Agent Activity Panel */}
<TabPanel>
<UserAgentActivity accessToken={accessToken} userRole={userRole} dateValue={dateValue} />
</TabPanel>
</TabPanels>
</TabGroup>
</div>
</div>
{/* CloudZero Export Modal */}
<CloudZeroExportModal
@@ -0,0 +1,183 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import AdvancedDatePicker from "./advanced_date_picker";
// Polyfill requestIdleCallback for test environment
beforeAll(() => {
if (typeof window !== "undefined" && !window.requestIdleCallback) {
window.requestIdleCallback = (callback: any) => {
const start = Date.now();
return setTimeout(() => {
callback({
didTimeout: false,
timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
});
}, 1) as any;
};
}
});
describe("AdvancedDatePicker", () => {
const mockOnValueChange = vi.fn();
const defaultValue = {
from: new Date("2025-01-01T12:00:00.000Z"),
to: new Date("2025-01-31T12:00:00.000Z"),
};
beforeEach(() => {
mockOnValueChange.mockClear();
});
const openDropdown = (container: HTMLElement) => {
// Find the clickable div that contains the clock icon
const trigger = container.querySelector('[role="img"][aria-label="clock-circle"]')?.closest("div.cursor-pointer");
if (trigger) {
fireEvent.click(trigger);
}
};
it("should render with default label", () => {
render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
expect(screen.getByText("Select Time Range")).toBeInTheDocument();
});
it("should render with custom label", () => {
render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} label="Custom Label" />);
expect(screen.getByText("Custom Label")).toBeInTheDocument();
});
it("should display formatted date range", () => {
render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
// The component displays date range in the format "D MMM, HH:mm - D MMM, HH:mm"
// Just check that the clock icon is present
expect(screen.getByLabelText("clock-circle")).toBeInTheDocument();
});
it("should open dropdown when clicked", () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
openDropdown(container);
// Check for relative time options
expect(screen.getByText("Today")).toBeInTheDocument();
expect(screen.getByText("Last 7 days")).toBeInTheDocument();
expect(screen.getByText("Last 30 days")).toBeInTheDocument();
});
it("should display relative time options", () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
openDropdown(container);
expect(screen.getByText("Today")).toBeInTheDocument();
expect(screen.getByText("Last 7 days")).toBeInTheDocument();
expect(screen.getByText("Last 30 days")).toBeInTheDocument();
expect(screen.getByText("Month to date")).toBeInTheDocument();
expect(screen.getByText("Year to date")).toBeInTheDocument();
});
it("should show date inputs in dropdown", () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
openDropdown(container);
const startDateInput = screen.getByDisplayValue("2025-01-01");
const endDateInput = screen.getByDisplayValue("2025-01-31");
expect(startDateInput).toBeInTheDocument();
expect(endDateInput).toBeInTheDocument();
});
it("should update date inputs when changed", () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
openDropdown(container);
const startDateInput = screen.getByDisplayValue("2025-01-01") as HTMLInputElement;
fireEvent.change(startDateInput, { target: { value: "2025-02-01" } });
expect(startDateInput.value).toBe("2025-02-01");
});
it("should show Apply and Cancel buttons", () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
openDropdown(container);
expect(screen.getByText("Apply")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
});
it("should close dropdown when Cancel is clicked", () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
openDropdown(container);
const cancelButton = screen.getByText("Cancel");
fireEvent.click(cancelButton);
// Dropdown should be closed, so relative time options shouldn't be visible
expect(screen.queryByText("Today")).not.toBeInTheDocument();
});
it("should call onValueChange when Apply is clicked", async () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
openDropdown(container);
const applyButton = screen.getByText("Apply");
fireEvent.click(applyButton);
await waitFor(() => {
expect(mockOnValueChange).toHaveBeenCalled();
});
});
it("should select relative time option", () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
openDropdown(container);
const todayOption = screen.getByText("Today");
fireEvent.click(todayOption);
// The option should be highlighted (bg-blue-50)
expect(todayOption.closest("div")).toHaveClass("bg-blue-50");
});
it("should show validation error for invalid date range", async () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
openDropdown(container);
const startDateInput = screen.getByDisplayValue("2025-01-01");
const endDateInput = screen.getByDisplayValue("2025-01-31");
// Set end date before start date
fireEvent.change(startDateInput, { target: { value: "2025-12-01" } });
fireEvent.change(endDateInput, { target: { value: "2025-01-01" } });
await waitFor(() => {
expect(screen.getByText("End date cannot be before start date")).toBeInTheDocument();
});
});
it("should disable Apply button when validation fails", async () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
openDropdown(container);
const startDateInput = screen.getByDisplayValue("2025-01-01");
const endDateInput = screen.getByDisplayValue("2025-01-31");
// Set end date before start date
fireEvent.change(startDateInput, { target: { value: "2025-12-01" } });
fireEvent.change(endDateInput, { target: { value: "2025-01-01" } });
await waitFor(() => {
// Find the button element (the Apply button's actual button element)
const applyButton = screen.getByText("Apply").closest("button");
expect(applyButton).toBeDisabled();
});
});
});
@@ -273,9 +273,8 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
};
return (
<div>
{label && <Text className="mb-2">{label}</Text>}
<div className="flex items-center gap-3">
{label && <Text className="text-sm font-medium text-gray-700 whitespace-nowrap">{label}</Text>}
<div className="relative" ref={dropdownRef}>
{/* Main input display */}
<div
@@ -305,7 +304,7 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
{/* Left side - Relative time options */}
<div className="w-1/2 border-r border-gray-200">
<div className="p-3 border-b border-gray-200">
<span className="text-sm font-semibold text-gray-900">Relative time (today, 7d, 30d, MTD, YTD)</span>
<span className="text-sm font-semibold text-gray-900">Relative time</span>
</div>
<div className="h-[350px] overflow-y-auto">
{relativeTimeOptions.map((option) => {
@@ -322,7 +321,7 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
{option.label}
</span>
<span
className={`text-xs px-2 py-1 rounded ${
className={`text-xs px-2 py-1 rounded capitalize ${
isSelected ? "text-blue-700 bg-blue-100" : "text-gray-500 bg-gray-100"
}`}
>
@@ -391,11 +390,17 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
</div>
)}
{/* Current selection preview */}
{/* Current selection time range */}
{tempValue.from && tempValue.to && validation.isValid && (
<div className="bg-blue-50 p-3 rounded-md">
<div className="text-xs text-blue-700 font-medium">Preview:</div>
<div className="text-sm text-blue-800">{formatDisplayRange(tempValue.from, tempValue.to)}</div>
<div className="bg-blue-50 p-3 rounded-md space-y-1">
<div className="text-xs text-blue-800">
<span className="font-medium">From:</span>{" "}
{moment(tempValue.from).format("MMM D, YYYY [at] HH:mm:ss")}
</div>
<div className="text-xs text-blue-800">
<span className="font-medium">To:</span>{" "}
{moment(tempValue.to).format("MMM D, YYYY [at] HH:mm:ss")}
</div>
</div>
)}
</div>
@@ -415,14 +420,6 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
</div>
)}
</div>
{/* Time range display below */}
{showTimeRange && value.from && value.to && (
<Text className="mt-2 text-xs text-gray-500">
{moment(value.from).format("MMM D, YYYY [at] HH:mm:ss")} -{" "}
{moment(value.to).format("MMM D, YYYY [at] HH:mm:ss")}
</Text>
)}
</div>
);
};
@@ -0,0 +1,195 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import UserAgentActivity from "./user_agent_activity";
import * as networking from "./networking";
// Polyfill ResizeObserver for test environment
beforeAll(() => {
if (typeof window !== "undefined" && !window.ResizeObserver) {
window.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
} as any;
}
});
// Mock the networking module
vi.mock("./networking", () => ({
userAgentSummaryCall: vi.fn(),
tagDauCall: vi.fn(),
tagWauCall: vi.fn(),
tagMauCall: vi.fn(),
tagDistinctCall: vi.fn(),
}));
// Mock PerUserUsage component
vi.mock("./per_user_usage", () => ({
default: () => <div>Per User Usage</div>,
}));
describe("UserAgentActivity", () => {
const mockUserAgentSummaryCall = vi.mocked(networking.userAgentSummaryCall);
const mockTagDauCall = vi.mocked(networking.tagDauCall);
const mockTagWauCall = vi.mocked(networking.tagWauCall);
const mockTagMauCall = vi.mocked(networking.tagMauCall);
const mockTagDistinctCall = vi.mocked(networking.tagDistinctCall);
const mockDistinctTagsData = {
results: [{ tag: "User-Agent: Chrome/1.0" }, { tag: "User-Agent: Firefox/2.0" }, { tag: "User-Agent: Safari/3.0" }],
};
const mockSummaryData = {
results: [
{
tag: "User-Agent: Chrome/1.0",
unique_users: 100,
total_requests: 1000,
successful_requests: 950,
failed_requests: 50,
total_tokens: 50000,
total_spend: 25.5,
},
{
tag: "User-Agent: Firefox/2.0",
unique_users: 80,
total_requests: 800,
successful_requests: 760,
failed_requests: 40,
total_tokens: 40000,
total_spend: 20.3,
},
],
};
const mockDauData = {
results: [
{
tag: "User-Agent: Chrome/1.0",
active_users: 50,
date: "2025-01-01",
},
{
tag: "User-Agent: Firefox/2.0",
active_users: 30,
date: "2025-01-01",
},
],
};
const mockWauData = {
results: [
{
tag: "User-Agent: Chrome/1.0",
active_users: 200,
date: "Week 1 (Jan 1)",
},
],
};
const mockMauData = {
results: [
{
tag: "User-Agent: Chrome/1.0",
active_users: 500,
date: "Month 1 (Jan)",
},
],
};
const defaultProps = {
accessToken: "test-token",
userRole: "Admin",
dateValue: {
from: new Date("2025-01-01"),
to: new Date("2025-01-31"),
},
};
beforeEach(() => {
mockUserAgentSummaryCall.mockClear();
mockTagDauCall.mockClear();
mockTagWauCall.mockClear();
mockTagMauCall.mockClear();
mockTagDistinctCall.mockClear();
mockTagDistinctCall.mockResolvedValue(mockDistinctTagsData);
mockUserAgentSummaryCall.mockResolvedValue(mockSummaryData);
mockTagDauCall.mockResolvedValue(mockDauData);
mockTagWauCall.mockResolvedValue(mockWauData);
mockTagMauCall.mockResolvedValue(mockMauData);
});
it("should render summary cards with user agent data", async () => {
render(<UserAgentActivity {...defaultProps} />);
// Wait for data to load
await waitFor(() => {
expect(mockUserAgentSummaryCall).toHaveBeenCalled();
expect(mockTagDistinctCall).toHaveBeenCalled();
});
// Check that summary section is displayed
expect(screen.getByText("Summary by User Agent")).toBeInTheDocument();
expect(screen.getByText("Performance metrics for different user agents")).toBeInTheDocument();
// Check that user agent cards are displayed
await waitFor(() => {
expect(screen.getByText("Chrome/1.0")).toBeInTheDocument();
expect(screen.getByText("Firefox/2.0")).toBeInTheDocument();
});
// Check that metrics are displayed
expect(screen.getAllByText("Success Requests").length).toBeGreaterThan(0);
expect(screen.getAllByText("Total Tokens").length).toBeGreaterThan(0);
expect(screen.getAllByText("Total Cost").length).toBeGreaterThan(0);
});
it("should switch between DAU, WAU, and MAU tabs", async () => {
render(<UserAgentActivity {...defaultProps} />);
// Wait for data to load
await waitFor(() => {
expect(mockTagDauCall).toHaveBeenCalled();
expect(mockTagWauCall).toHaveBeenCalled();
expect(mockTagMauCall).toHaveBeenCalled();
});
// Check default DAU tab content
expect(screen.getByText("Daily Active Users - Last 7 Days")).toBeInTheDocument();
// Find all WAU tab buttons (there might be multiple)
const wauTabs = screen.getAllByText("WAU");
fireEvent.click(wauTabs[0]);
// Check WAU tab content
await waitFor(() => {
expect(screen.getByText("Weekly Active Users - Last 7 Weeks")).toBeInTheDocument();
});
// Find all MAU tab buttons
const mauTabs = screen.getAllByText("MAU");
fireEvent.click(mauTabs[0]);
// Check MAU tab content
await waitFor(() => {
expect(screen.getByText("Monthly Active Users - Last 7 Months")).toBeInTheDocument();
});
});
it("should display filter dropdown and allow tag selection", async () => {
render(<UserAgentActivity {...defaultProps} />);
// Wait for tags to load
await waitFor(() => {
expect(mockTagDistinctCall).toHaveBeenCalled();
});
// Check that filter label is present
expect(screen.getByText("Filter by User Agents")).toBeInTheDocument();
// The Ant Design Select component should be in the document with placeholder
const selectElement = screen.getByText("All User Agents");
expect(selectElement).toBeInTheDocument();
});
});
@@ -15,7 +15,6 @@ import {
} from "@tremor/react";
import { Select, Tooltip } from "antd";
import { userAgentSummaryCall, tagDauCall, tagWauCall, tagMauCall, tagDistinctCall } from "./networking";
import AdvancedDatePicker from "./shared/advanced_date_picker";
import PerUserUsage from "./per_user_usage";
import { DateRangePickerValue } from "@tremor/react";
import { ChartLoader } from "./shared/chart_loader";
@@ -58,9 +57,11 @@ interface DistinctTagsResponse {
interface UserAgentActivityProps {
accessToken: string | null;
userRole: string | null;
dateValue: DateRangePickerValue;
onDateChange?: (value: DateRangePickerValue) => void; // Optional - not used anymore
}
const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, userRole }) => {
const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, userRole, dateValue, onDateChange }) => {
// Maximum number of categories to show in charts to prevent color palette overflow
const MAX_CATEGORIES = 10;
@@ -70,11 +71,6 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
const [mauData, setMauData] = useState<ActiveUsersAnalyticsResponse>({ results: [] });
const [summaryData, setSummaryData] = useState<TagSummaryResponse>({ results: [] });
const [dateValue, setDateValue] = useState<DateRangePickerValue>({
from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
to: new Date(),
});
const [userAgentFilter, setUserAgentFilter] = useState<string>("");
// Tag filtering state
@@ -88,8 +84,6 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
const [mauLoading, setMauLoading] = useState(false);
const [summaryLoading, setSummaryLoading] = useState(false);
const [isDateChanging, setIsDateChanging] = useState(false);
// Use today's date as the end date for all API calls
const today = new Date();
@@ -180,20 +174,9 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
console.error("Failed to fetch user agent summary data:", error);
} finally {
setSummaryLoading(false);
setIsDateChanging(false);
}
};
// Super responsive date change handler
const handleDateChange = (newValue: DateRangePickerValue) => {
// Instant visual feedback
setIsDateChanging(true);
setSummaryLoading(true);
// Update date immediately for UI responsiveness
setDateValue(newValue);
};
// Effect to fetch available tags on mount
useEffect(() => {
fetchAvailableTags();
@@ -425,12 +408,11 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
</div>
</div>
{/* Date Range Picker within Summary */}
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
{/* Date Range Picker is controlled by parent component */}
{/* Top 4 User Agents Cards */}
{summaryLoading ? (
<ChartLoader isDateChanging={isDateChanging} />
<ChartLoader isDateChanging={false} />
) : (
<Grid numItems={4} className="gap-4">
{(summaryData.results || []).slice(0, 4).map((tag, index) => {