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(<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"); + }); }); 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";