diff --git a/ui/litellm-dashboard/src/components/per_user_usage.test.tsx b/ui/litellm-dashboard/src/components/per_user_usage.test.tsx
new file mode 100644
index 000000000000..66aaf015acad
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/per_user_usage.test.tsx
@@ -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();
+
+ 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();
+
+ 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);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/per_user_usage.tsx b/ui/litellm-dashboard/src/components/per_user_usage.tsx
index 59aed14292e7..30933700cf34 100644
--- a/ui/litellm-dashboard/src/components/per_user_usage.tsx
+++ b/ui/litellm-dashboard/src/components/per_user_usage.tsx
@@ -8,7 +8,6 @@ import {
TableHeaderCell,
TableBody,
TableCell,
- BarChart,
Text,
Button,
Tab,
@@ -17,6 +16,7 @@ import {
TabPanel,
TabPanels,
} from "@tremor/react";
+import { BarChart } from "@/components/shared/charts";
import { perUserAnalyticsCall } from "./networking";
interface PerUserMetrics {
diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx
index 7b67720e9e5c..b2facde3e65e 100644
--- a/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx
+++ b/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx
@@ -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(),
@@ -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
@@ -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
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);
+ };
+
+ 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();
+
+ 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();
+
+ 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();
+
+ const panel = getPanelForTitle("Monthly Active Users - Last 7 Months");
+ await waitFor(() => {
+ expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2);
+ });
+
+ expectStackedTwoCategoryChart(panel, "Month 1");
+ });
});
diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.tsx
index 8b5f303670da..6d262ec23231 100644
--- a/ui/litellm-dashboard/src/components/user_agent_activity.tsx
+++ b/ui/litellm-dashboard/src/components/user_agent_activity.tsx
@@ -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";