Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions ui/litellm-dashboard/src/components/per_user_usage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import PerUserUsage from "./per_user_usage";
import * as networking from "./networking";

vi.mock("./networking", () => ({
perUserAnalyticsCall: vi.fn(),
}));

type UserRow = {
user_id: string;
user_email: string | null;
user_agent: string | null;
successful_requests: number;
failed_requests: number;
total_requests: number;
total_tokens: number;
spend: number;
};

const userRow = (userId: string, userAgent: string | null, successfulRequests: number): UserRow => ({
user_id: userId,
user_email: null,
user_agent: userAgent,
successful_requests: successfulRequests,
failed_requests: 0,
total_requests: successfulRequests,
total_tokens: 100,
spend: 1,
});

describe("PerUserUsage", () => {
const mockPerUserAnalyticsCall = vi.mocked(networking.perUserAnalyticsCall);

const mockResponse = {
results: [
userRow("u1", "curl/8.0", 5),
userRow("u2", "curl/8.0", 50),
userRow("u3", "curl/8.0", 8),
userRow("u4", null, 7),
userRow("u5", null, 500),
],
total_count: 5,
page: 1,
page_size: 50,
total_pages: 1,
};

const defaultProps = {
accessToken: "test-token",
selectedTags: [],
formatAbbreviatedNumber: (value: number) => String(value),
};

beforeEach(() => {
mockPerUserAnalyticsCall.mockClear();
mockPerUserAnalyticsCall.mockResolvedValue(mockResponse);
});

it("renders the user details table by default", async () => {
render(<PerUserUsage {...defaultProps} />);

await waitFor(() => {
expect(mockPerUserAnalyticsCall).toHaveBeenCalled();
});

expect(screen.getByText("Per User Usage")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("u1")).toBeInTheDocument();
});
});

it("renders the usage distribution as a stacked bar chart with the explicit palette and users formatter", async () => {
render(<PerUserUsage {...defaultProps} />);

await waitFor(() => {
expect(mockPerUserAnalyticsCall).toHaveBeenCalled();
});

fireEvent.click(screen.getByText("Usage Distribution"));

const panel = screen.getByText("User Usage Distribution").closest("div")?.parentElement;
expect(panel).not.toBeNull();

await waitFor(() => {
expect(panel!.querySelectorAll("path.recharts-rectangle")).toHaveLength(4);
});

const chart = panel!.querySelector('[data-slot="chart"]');
expect(chart).not.toBeNull();
expect(chart!.querySelectorAll(".recharts-bar")).toHaveLength(2);

const rectangles = Array.from(chart!.querySelectorAll("path.recharts-rectangle"));
const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill")));
expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-green-500, #22c55e)"]));

const xPositions = new Set(rectangles.map((rect) => rect.getAttribute("d")?.match(/^M\s*([\d.]+)/)?.[1]));
expect(xPositions.size).toBe(3);

expect(chart!.textContent).toContain("curl/8.0");
expect(chart!.textContent).toContain("Unknown");
for (const bucket of [
"1-9 requests",
"10-99 requests",
"100-999 requests",
"1K-9.9K requests",
"10K-99.9K requests",
"100K+ requests",
]) {
expect(chart!.textContent).toContain(bucket);
}

const tickTexts = Array.from(chart!.querySelectorAll(".recharts-cartesian-axis-tick-value")).map(
(tick) => tick.textContent ?? "",
);
expect(tickTexts.some((tick) => / users$/.test(tick))).toBe(true);
});
});
2 changes: 1 addition & 1 deletion ui/litellm-dashboard/src/components/per_user_usage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
TableHeaderCell,
TableBody,
TableCell,
BarChart,
Text,
Button,
Tab,
Expand All @@ -17,6 +16,7 @@
TabPanel,
TabPanels,
} from "@tremor/react";
import { BarChart } from "@/components/shared/charts";
import { perUserAnalyticsCall } from "./networking";

interface PerUserMetrics {
Expand Down Expand Up @@ -47,7 +47,7 @@
const PerUserUsage: React.FC<PerUserUsageProps> = ({ accessToken, selectedTags, formatAbbreviatedNumber }) => {
// Maximum number of user agent categories to show in charts to prevent color palette overflow
const MAX_USER_AGENTS = 8;
const [perUserData, setPerUserData] = useState<PerUserAnalyticsResponse>({

Check warning on line 50 in ui/litellm-dashboard/src/components/per_user_usage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 5 properties passed inline as an argument; assign it to a named variable first
results: [],
total_count: 0,
page: 1,
Expand Down Expand Up @@ -79,7 +79,7 @@

useEffect(() => {
fetchPerUserData();
}, [accessToken, selectedTags, currentPage]);

Check warning on line 82 in ui/litellm-dashboard/src/components/per_user_usage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

React Hook useEffect has a missing dependency: 'fetchPerUserData'. Either include it or remove the dependency array

const handleNextPage = () => {
if (currentPage < perUserData.total_pages) {
Expand Down Expand Up @@ -219,7 +219,7 @@

// Convert to chart data format for stacked bar chart
return Object.entries(categories).map(([categoryName, category]) => {
const dataPoint: Record<string, any> = { category: categoryName };

Check warning on line 222 in ui/litellm-dashboard/src/components/per_user_usage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

// Add count for each top user agent
topUserAgents.forEach((agent) => {
Expand Down
103 changes: 89 additions & 14 deletions ui/litellm-dashboard/src/components/user_agent_activity.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,8 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import { describe, it, expect, vi, beforeEach } 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(),
Expand Down Expand Up @@ -135,8 +124,8 @@ describe("UserAgentActivity", () => {

// Check that user agent cards are displayed
await waitFor(() => {
expect(screen.getByText("Chrome/1.0")).toBeInTheDocument();
expect(screen.getByText("Firefox/2.0")).toBeInTheDocument();
expect(screen.getAllByText("Chrome/1.0").length).toBeGreaterThan(0);
expect(screen.getAllByText("Firefox/2.0").length).toBeGreaterThan(0);
});

// Check that metrics are displayed
Expand Down Expand Up @@ -192,4 +181,90 @@ describe("UserAgentActivity", () => {
const selectElement = screen.getByText("All User Agents");
expect(selectElement).toBeInTheDocument();
});

const getPanelForTitle = (title: string): HTMLElement => {
// Assumes two wrapper divs between the Tremor <Title> and the panel root; update if Tremor's TabPanel depth changes.
const panel = screen.getByText(title).closest("div")?.parentElement;
expect(panel).not.toBeNull();
return panel!;
};

const expectStackedTwoCategoryChart = (panel: HTMLElement, firstBucketLabel: string) => {
const chart = panel.querySelector('[data-slot="chart"]');
expect(chart).not.toBeNull();
expect(chart!.querySelectorAll(".recharts-bar")).toHaveLength(2);

const rectangles = Array.from(chart!.querySelectorAll("path.recharts-rectangle"));
const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill")));
expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"]));

const xPositions = new Set(rectangles.map((rect) => rect.getAttribute("d")?.match(/^M\s*([\d.]+)/)?.[1]));
expect(xPositions.size).toBe(1);

expect(chart!.textContent).toContain("Chrome/1.0");
expect(chart!.textContent).toContain("Firefox/2.0");
expect(chart!.textContent).toContain(firstBucketLabel);

const tickTexts = Array.from(chart!.querySelectorAll(".recharts-cartesian-axis-tick-value")).map(
(tick) => tick.textContent ?? "",
);
expect(tickTexts.some((tick) => /^\d+K$/.test(tick))).toBe(true);
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.

it("renders the DAU chart stacked with default color cycle and abbreviated axis ticks", async () => {
const firstBucketDate = new Date();
firstBucketDate.setDate(firstBucketDate.getDate() - 6);
const todayStr = new Date().toISOString().split("T")[0];
mockTagDauCall.mockResolvedValue({
results: [
{ tag: "User-Agent: Chrome/1.0", active_users: 4000, date: todayStr },
{ tag: "User-Agent: Firefox/2.0", active_users: 2600, date: todayStr },
],
});

render(<UserAgentActivity {...defaultProps} />);

const panel = getPanelForTitle("Daily Active Users - Last 7 Days");
await waitFor(() => {
expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2);
});

expectStackedTwoCategoryChart(panel, firstBucketDate.toISOString().split("T")[0]);
});

it("renders the WAU chart stacked with week buckets and abbreviated axis ticks", async () => {
mockTagWauCall.mockResolvedValue({
results: [
{ tag: "User-Agent: Chrome/1.0", active_users: 2000, date: "Week 3 (Jan 15)" },
{ tag: "User-Agent: Firefox/2.0", active_users: 1500, date: "Week 3 (Jan 15)" },
],
});

render(<UserAgentActivity {...defaultProps} />);

const panel = getPanelForTitle("Weekly Active Users - Last 7 Weeks");
await waitFor(() => {
expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2);
});

expectStackedTwoCategoryChart(panel, "Week 1");
});

it("renders the MAU chart stacked with month buckets and abbreviated axis ticks", async () => {
mockTagMauCall.mockResolvedValue({
results: [
{ tag: "User-Agent: Chrome/1.0", active_users: 5000, date: "Month 2 (Feb)" },
{ tag: "User-Agent: Firefox/2.0", active_users: 3000, date: "Month 2 (Feb)" },
],
});

render(<UserAgentActivity {...defaultProps} />);

const panel = getPanelForTitle("Monthly Active Users - Last 7 Months");
await waitFor(() => {
expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2);
});

expectStackedTwoCategoryChart(panel, "Month 1");
});
});
16 changes: 2 additions & 14 deletions ui/litellm-dashboard/src/components/user_agent_activity.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,7 @@
import React, { useState, useEffect } from "react";
import {
Card,
Title,
Text,
Grid,
BarChart,
Metric,
Subtitle,
Tab,
TabGroup,
TabList,
TabPanel,
TabPanels,
} from "@tremor/react";
import { Card, Title, Text, Grid, Metric, Subtitle, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import { Select, Tooltip } from "antd";
import { BarChart } from "@/components/shared/charts";
import { userAgentSummaryCall, tagDauCall, tagWauCall, tagMauCall, tagDistinctCall } from "./networking";
import PerUserUsage from "./per_user_usage";
import { DateRangePickerValue } from "@tremor/react";
Expand Down Expand Up @@ -180,7 +168,7 @@
// Effect to fetch available tags on mount
useEffect(() => {
fetchAvailableTags();
}, [accessToken]);

Check warning on line 171 in ui/litellm-dashboard/src/components/user_agent_activity.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

React Hook useEffect has a missing dependency: 'fetchAvailableTags'. Either include it or remove the dependency array

// Effect for DAU/WAU/MAU data (independent of date picker)
useEffect(() => {
Expand All @@ -193,7 +181,7 @@
}, 50);

return () => clearTimeout(timeoutId);
}, [accessToken, userAgentFilter, selectedTags]);

Check warning on line 184 in ui/litellm-dashboard/src/components/user_agent_activity.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

React Hook useEffect has missing dependencies: 'fetchDauData', 'fetchMauData', and 'fetchWauData'. Either include them or remove the dependency array

// Effect for summary data (depends on date picker)
useEffect(() => {
Expand All @@ -204,7 +192,7 @@
}, 50);

return () => clearTimeout(timeoutId);
}, [accessToken, dateValue, selectedTags]);

Check warning on line 195 in ui/litellm-dashboard/src/components/user_agent_activity.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

React Hook useEffect has a missing dependency: 'fetchSummaryData'. Either include it or remove the dependency array

// Helper function to extract user agent from tag
const extractUserAgent = (tag: string): string => {
Expand Down Expand Up @@ -245,7 +233,7 @@

// Prepare daily chart data (DAU) - always show last 7 days
const generateDailyChartData = () => {
const chartData: any[] = [];

Check warning on line 236 in ui/litellm-dashboard/src/components/user_agent_activity.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
const endDate = new Date();

// Generate all 7 days
Expand All @@ -254,7 +242,7 @@
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD format

const dayEntry: any = { date: dateStr };

Check warning on line 245 in ui/litellm-dashboard/src/components/user_agent_activity.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

// Initialize all user agents to 0
allDauTags.forEach((tag) => {
Expand All @@ -281,11 +269,11 @@

// Prepare weekly chart data (WAU) - always show all 7 weeks
const generateWeeklyChartData = () => {
const chartData: any[] = [];

Check warning on line 272 in ui/litellm-dashboard/src/components/user_agent_activity.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

// Generate all 7 weeks (Week 1 through Week 7)
for (let weekNum = 1; weekNum <= 7; weekNum++) {
const weekEntry: any = { week: `Week ${weekNum}` };

Check warning on line 276 in ui/litellm-dashboard/src/components/user_agent_activity.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

// Initialize all user agents to 0
allWauTags.forEach((tag) => {
Expand Down
Loading