mirror of
https://github.com/tiennm99/litellm.git
synced 2026-06-18 03:31:23 +00:00
Change usage page to have a parent date picker (#16264)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user