diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index 405f8329b67..a74d3c108d6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -31,7 +31,7 @@ import { import * as React from "react"; import { useRouter, usePathname } from "next/navigation"; import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "@/utils/roles"; -import UsageIndicator from "@/components/usage_indicator"; +import UsageIndicator from "@/components/UsageIndicator"; import { serverRootPath } from "@/components/networking"; const { Sider } = Layout; @@ -64,7 +64,7 @@ const getBasePath = () => { const raw = process.env.NEXT_PUBLIC_BASE_URL ?? ""; const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes const uiPath = trimmed ? `/${trimmed}/` : "/"; - + // If serverRootPath is set and not "/", prepend it to the UI path if (serverRootPath && serverRootPath !== "/") { // Remove trailing slash from serverRootPath and ensure uiPath has no leading slash for proper joining @@ -72,7 +72,7 @@ const getBasePath = () => { const cleanUiPath = uiPath.replace(/^\/+/, ""); return `${cleanServerRoot}/${cleanUiPath}`; } - + return uiPath; }; @@ -153,170 +153,170 @@ const toHref = (slugOrPath: string) => { // ----- Menu config (unchanged labels/icons; same appearance) ----- const menuItems: MenuItemCfg[] = [ - { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, - { - key: "3", - page: "llm-playground", - label: "Test Key", - icon: , - roles: rolesWithWriteAccess, - }, - { - key: "2", - page: "models", - label: "Models + Endpoints", - icon: , - roles: rolesWithWriteAccess, - }, - { - key: "12", - page: "new_usage", - label: "Usage", - icon: , - roles: [...all_admin_roles, ...internalUserRoles], - }, - { key: "6", page: "teams", label: "Teams", icon: }, - { - key: "17", - page: "organizations", - label: "Organizations", - icon: , - roles: all_admin_roles, - }, - { - key: "5", - page: "users", - label: "Internal Users", - icon: , - roles: all_admin_roles, - }, - { key: "14", page: "api_ref", label: "API Reference", icon: }, - { - key: "16", - page: "model-hub-table", - label: "Model Hub", - icon: , - }, - { key: "15", page: "logs", label: "Logs", icon: }, - { - key: "11", - page: "guardrails", - label: "Guardrails", - icon: , - roles: all_admin_roles, - }, - { - key: "28", - page: "policies", - label: "Policies", - icon: , - roles: all_admin_roles, - }, - { - key: "26", - page: "tools", - label: "Tools", - icon: , - children: [ - { key: "18", page: "mcp-servers", label: "MCP Servers", icon: }, - { - key: "21", - page: "vector-stores", - label: "Vector Stores", - icon: , - roles: all_admin_roles, - }, - ], - }, - { - key: "experimental", - page: "experimental", - label: "Experimental", - icon: , - children: [ - { - key: "9", - page: "caching", - label: "Caching", - icon: , - roles: all_admin_roles, - }, - { - key: "25", - page: "prompts", - label: "Prompts", - icon: , - roles: all_admin_roles, - }, - { - key: "10", - page: "budgets", - label: "Budgets", - icon: , - roles: all_admin_roles, - }, - { - key: "20", - page: "transform-request", - label: "API Playground", - icon: , - roles: [...all_admin_roles, ...internalUserRoles], - }, - { - key: "19", - page: "tag-management", - label: "Tag Management", - icon: , - roles: all_admin_roles, - }, - { - key: "27", - page: "claude-code-plugins", - label: "Claude Code Plugins", - icon: , - roles: all_admin_roles, - }, - { key: "4", page: "usage", label: "Old Usage", icon: }, - ], - }, - { - key: "settings", - page: "settings", - label: "Settings", - icon: , - roles: all_admin_roles, - children: [ - { - key: "11", - page: "general-settings", - label: "Router Settings", - icon: , - roles: all_admin_roles, - }, - { - key: "8", - page: "settings", - label: "Logging & Alerts", - icon: , - roles: all_admin_roles, - }, - { - key: "13", - page: "admin-panel", - label: "Admin Settings", - icon: , - roles: all_admin_roles, - }, - { - key: "14", - page: "ui-theme", - label: "UI Theme", - icon: , - roles: all_admin_roles, - }, - ], - }, - ]; + { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, + { + key: "3", + page: "llm-playground", + label: "Test Key", + icon: , + roles: rolesWithWriteAccess, + }, + { + key: "2", + page: "models", + label: "Models + Endpoints", + icon: , + roles: rolesWithWriteAccess, + }, + { + key: "12", + page: "new_usage", + label: "Usage", + icon: , + roles: [...all_admin_roles, ...internalUserRoles], + }, + { key: "6", page: "teams", label: "Teams", icon: }, + { + key: "17", + page: "organizations", + label: "Organizations", + icon: , + roles: all_admin_roles, + }, + { + key: "5", + page: "users", + label: "Internal Users", + icon: , + roles: all_admin_roles, + }, + { key: "14", page: "api_ref", label: "API Reference", icon: }, + { + key: "16", + page: "model-hub-table", + label: "Model Hub", + icon: , + }, + { key: "15", page: "logs", label: "Logs", icon: }, + { + key: "11", + page: "guardrails", + label: "Guardrails", + icon: , + roles: all_admin_roles, + }, + { + key: "28", + page: "policies", + label: "Policies", + icon: , + roles: all_admin_roles, + }, + { + key: "26", + page: "tools", + label: "Tools", + icon: , + children: [ + { key: "18", page: "mcp-servers", label: "MCP Servers", icon: }, + { + key: "21", + page: "vector-stores", + label: "Vector Stores", + icon: , + roles: all_admin_roles, + }, + ], + }, + { + key: "experimental", + page: "experimental", + label: "Experimental", + icon: , + children: [ + { + key: "9", + page: "caching", + label: "Caching", + icon: , + roles: all_admin_roles, + }, + { + key: "25", + page: "prompts", + label: "Prompts", + icon: , + roles: all_admin_roles, + }, + { + key: "10", + page: "budgets", + label: "Budgets", + icon: , + roles: all_admin_roles, + }, + { + key: "20", + page: "transform-request", + label: "API Playground", + icon: , + roles: [...all_admin_roles, ...internalUserRoles], + }, + { + key: "19", + page: "tag-management", + label: "Tag Management", + icon: , + roles: all_admin_roles, + }, + { + key: "27", + page: "claude-code-plugins", + label: "Claude Code Plugins", + icon: , + roles: all_admin_roles, + }, + { key: "4", page: "usage", label: "Old Usage", icon: }, + ], + }, + { + key: "settings", + page: "settings", + label: "Settings", + icon: , + roles: all_admin_roles, + children: [ + { + key: "11", + page: "general-settings", + label: "Router Settings", + icon: , + roles: all_admin_roles, + }, + { + key: "8", + page: "settings", + label: "Logging & Alerts", + icon: , + roles: all_admin_roles, + }, + { + key: "13", + page: "admin-panel", + label: "Admin Settings", + icon: , + roles: all_admin_roles, + }, + { + key: "14", + page: "ui-theme", + label: "UI Theme", + icon: , + roles: all_admin_roles, + }, + ], + }, +]; const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => { const router = useRouter(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableUsageIndicator.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableUsageIndicator.test.ts new file mode 100644 index 00000000000..bd0e69c0de3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableUsageIndicator.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useDisableUsageIndicator } from "./useDisableUsageIndicator"; +import { LOCAL_STORAGE_EVENT } from "@/utils/localStorageUtils"; + +describe("useDisableUsageIndicator", () => { + const STORAGE_KEY = "disableUsageIndicator"; + + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("should return false when localStorage is empty", () => { + const { result } = renderHook(() => useDisableUsageIndicator()); + + expect(result.current).toBe(false); + }); + + it("should return false when localStorage value is not 'true'", () => { + localStorage.setItem(STORAGE_KEY, "false"); + + const { result } = renderHook(() => useDisableUsageIndicator()); + + expect(result.current).toBe(false); + }); + + it("should return true when localStorage value is 'true'", () => { + localStorage.setItem(STORAGE_KEY, "true"); + + const { result } = renderHook(() => useDisableUsageIndicator()); + + expect(result.current).toBe(true); + }); + + it("should return false when localStorage value is an empty string", () => { + localStorage.setItem(STORAGE_KEY, ""); + + const { result } = renderHook(() => useDisableUsageIndicator()); + + expect(result.current).toBe(false); + }); + + it("should update when storage event fires for the correct key", async () => { + const { result } = renderHook(() => useDisableUsageIndicator()); + + expect(result.current).toBe(false); + + await act(async () => { + localStorage.setItem(STORAGE_KEY, "true"); + const storageEvent = new StorageEvent("storage", { + key: STORAGE_KEY, + newValue: "true", + }); + window.dispatchEvent(storageEvent); + }); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it("should not update when storage event fires for a different key", () => { + localStorage.setItem(STORAGE_KEY, "false"); + const { result } = renderHook(() => useDisableUsageIndicator()); + + expect(result.current).toBe(false); + + const storageEvent = new StorageEvent("storage", { + key: "otherKey", + newValue: "true", + }); + window.dispatchEvent(storageEvent); + + expect(result.current).toBe(false); + }); + + it("should update when custom LOCAL_STORAGE_EVENT fires for the correct key", async () => { + const { result } = renderHook(() => useDisableUsageIndicator()); + + expect(result.current).toBe(false); + + await act(async () => { + localStorage.setItem(STORAGE_KEY, "true"); + const customEvent = new CustomEvent(LOCAL_STORAGE_EVENT, { + detail: { key: STORAGE_KEY }, + }); + window.dispatchEvent(customEvent); + }); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it("should not update when custom LOCAL_STORAGE_EVENT fires for a different key", () => { + localStorage.setItem(STORAGE_KEY, "false"); + const { result } = renderHook(() => useDisableUsageIndicator()); + + expect(result.current).toBe(false); + + const customEvent = new CustomEvent(LOCAL_STORAGE_EVENT, { + detail: { key: "otherKey" }, + }); + window.dispatchEvent(customEvent); + + expect(result.current).toBe(false); + }); + + it("should update when localStorage changes from false to true via custom event", async () => { + localStorage.setItem(STORAGE_KEY, "false"); + const { result } = renderHook(() => useDisableUsageIndicator()); + + expect(result.current).toBe(false); + + await act(async () => { + localStorage.setItem(STORAGE_KEY, "true"); + const customEvent = new CustomEvent(LOCAL_STORAGE_EVENT, { + detail: { key: STORAGE_KEY }, + }); + window.dispatchEvent(customEvent); + }); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it("should update when localStorage changes from true to false via storage event", async () => { + localStorage.setItem(STORAGE_KEY, "true"); + const { result } = renderHook(() => useDisableUsageIndicator()); + + expect(result.current).toBe(true); + + await act(async () => { + localStorage.setItem(STORAGE_KEY, "false"); + const storageEvent = new StorageEvent("storage", { + key: STORAGE_KEY, + newValue: "false", + }); + window.dispatchEvent(storageEvent); + }); + + await waitFor(() => { + expect(result.current).toBe(false); + }); + }); + + it("should cleanup event listeners on unmount", () => { + const addEventListenerSpy = vi.spyOn(window, "addEventListener"); + const removeEventListenerSpy = vi.spyOn(window, "removeEventListener"); + + const { unmount } = renderHook(() => useDisableUsageIndicator()); + + expect(addEventListenerSpy).toHaveBeenCalledTimes(2); + expect(addEventListenerSpy).toHaveBeenCalledWith("storage", expect.any(Function)); + expect(addEventListenerSpy).toHaveBeenCalledWith(LOCAL_STORAGE_EVENT, expect.any(Function)); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledTimes(2); + expect(removeEventListenerSpy).toHaveBeenCalledWith("storage", expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith(LOCAL_STORAGE_EVENT, expect.any(Function)); + }); + + it("should handle multiple hooks independently", async () => { + const { result: result1 } = renderHook(() => useDisableUsageIndicator()); + const { result: result2 } = renderHook(() => useDisableUsageIndicator()); + + expect(result1.current).toBe(false); + expect(result2.current).toBe(false); + + await act(async () => { + localStorage.setItem(STORAGE_KEY, "true"); + const customEvent = new CustomEvent(LOCAL_STORAGE_EVENT, { + detail: { key: STORAGE_KEY }, + }); + window.dispatchEvent(customEvent); + }); + + await waitFor(() => { + expect(result1.current).toBe(true); + expect(result2.current).toBe(true); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableUsageIndicator.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableUsageIndicator.ts new file mode 100644 index 00000000000..7f4e2295090 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableUsageIndicator.ts @@ -0,0 +1,33 @@ +import { getLocalStorageItem, LOCAL_STORAGE_EVENT } from "@/utils/localStorageUtils"; +import { useSyncExternalStore } from "react"; + +function subscribe(callback: () => void) { + const onStorage = (e: StorageEvent) => { + if (e.key === "disableUsageIndicator") { + callback(); + } + }; + + const onCustom = (e: Event) => { + const { key } = (e as CustomEvent).detail; + if (key === "disableUsageIndicator") { + callback(); + } + }; + + window.addEventListener("storage", onStorage); + window.addEventListener(LOCAL_STORAGE_EVENT, onCustom); + + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(LOCAL_STORAGE_EVENT, onCustom); + }; +} + +function getSnapshot() { + return getLocalStorageItem("disableUsageIndicator") === "true"; +} + +export function useDisableUsageIndicator() { + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index f80af33f9e2..90e02ae447b 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -1,5 +1,6 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; +import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator"; import { emitLocalStorageChange, getLocalStorageItem, @@ -27,6 +28,7 @@ interface UserDropdownProps { const UserDropdown: React.FC = ({ onLogout }) => { const { userId, userEmail, userRole, premiumUser } = useAuthorized(); const disableShowPrompts = useDisableShowPrompts(); + const disableUsageIndicator = useDisableUsageIndicator(); const [disableShowNewBadge, setDisableShowNewBadge] = useState(false); useEffect(() => { @@ -129,6 +131,23 @@ const UserDropdown: React.FC = ({ onLogout }) => { aria-label="Toggle hide all prompts" /> + + Hide Usage Indicator + { + if (checked) { + setLocalStorageItem("disableUsageIndicator", "true"); + emitLocalStorageChange("disableUsageIndicator"); + } else { + removeLocalStorageItem("disableUsageIndicator"); + emitLocalStorageChange("disableUsageIndicator"); + } + }} + aria-label="Toggle hide usage indicator" + /> + ); diff --git a/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx b/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx new file mode 100644 index 00000000000..71a37263980 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx @@ -0,0 +1,186 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import UsageIndicator from "./UsageIndicator"; + +vi.mock("./networking", () => ({ + getRemainingUsers: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/useDisableUsageIndicator", () => ({ + useDisableUsageIndicator: vi.fn(() => false), +})); + +import { getRemainingUsers } from "./networking"; + +const mockGetRemainingUsers = vi.mocked(getRemainingUsers); + +const DEFAULT_USAGE_DATA = { + total_users: 100, + total_users_used: 1, + total_users_remaining: 99, + total_teams: null, + total_teams_used: 0, + total_teams_remaining: null, +}; + +describe("UsageIndicator", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetRemainingUsers.mockResolvedValue(DEFAULT_USAGE_DATA); + }); + + it("should render when given access token and usage data loads", async () => { + render(); + + await screen.findByText("Usage"); + + expect(screen.getByText("Usage")).toBeInTheDocument(); + }); + + it("should not show Near limit when users usage is below 80% (1/100 -> 1%)", async () => { + render(); + + await screen.findByText("Usage"); + + expect(screen.queryByText("Near limit")).not.toBeInTheDocument(); + }); + + it("should render nothing when both total_users and total_teams are null", async () => { + mockGetRemainingUsers.mockResolvedValue({ + total_users: null, + total_teams: null, + total_users_used: 520, + total_teams_used: 4, + total_teams_remaining: null, + total_users_remaining: null, + }); + + render(); + + await waitFor(() => { + expect(screen.queryByText("Usage")).not.toBeInTheDocument(); + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + }); + }); + + it("should show Near limit for Teams when at 80% usage (4/5)", async () => { + mockGetRemainingUsers.mockResolvedValue({ + total_users: null, + total_users_used: 0, + total_users_remaining: null, + total_teams: 5, + total_teams_used: 4, + total_teams_remaining: 1, + }); + + render(); + + await screen.findByText("Usage"); + + expect(screen.getByText("Teams")).toBeInTheDocument(); + expect(screen.getByText("Near limit")).toBeInTheDocument(); + }); + + it("should show Over limit for Users when usage exceeds 100% (105/100)", async () => { + mockGetRemainingUsers.mockResolvedValue({ + total_users: 100, + total_users_used: 105, + total_users_remaining: -5, + total_teams: null, + total_teams_used: 0, + total_teams_remaining: null, + }); + + render(); + + await screen.findByText("Usage"); + + expect(screen.getByText("Users")).toBeInTheDocument(); + expect(screen.getByText("Over limit")).toBeInTheDocument(); + }); + + it("should show Over limit for Teams when usage exceeds 100%", async () => { + mockGetRemainingUsers.mockResolvedValue({ + total_users: null, + total_users_used: 0, + total_users_remaining: null, + total_teams: 10, + total_teams_used: 12, + total_teams_remaining: -2, + }); + + render(); + + await screen.findByText("Usage"); + + expect(screen.getByText("Teams")).toBeInTheDocument(); + expect(screen.getByText("Over limit")).toBeInTheDocument(); + }); + + it("should render nothing when accessToken is null", () => { + render(); + + expect(mockGetRemainingUsers).not.toHaveBeenCalled(); + expect(screen.queryByText("Usage")).not.toBeInTheDocument(); + }); + + it("should render nothing when disableUsageIndicator is true", async () => { + const { useDisableUsageIndicator } = await import("@/app/(dashboard)/hooks/useDisableUsageIndicator"); + (useDisableUsageIndicator as ReturnType).mockReturnValue(true); + + render(); + + await waitFor(() => { + expect(screen.queryByText("Usage")).not.toBeInTheDocument(); + }); + + (useDisableUsageIndicator as ReturnType).mockReturnValue(false); + }); + + it("should show Loading while fetching", () => { + mockGetRemainingUsers.mockImplementation(() => new Promise(() => {})); + + render(); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("should show error message when fetch fails", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + mockGetRemainingUsers.mockRejectedValue(new Error("Network error")); + + render(); + + expect(await screen.findByText("Failed to load usage data")).toBeInTheDocument(); + + consoleSpy.mockRestore(); + }); + + it("should minimize when user clicks minimize button", async () => { + const user = userEvent.setup(); + render(); + + await screen.findByText("Usage"); + + const minimizeButton = screen.getByTitle("Minimize"); + await user.click(minimizeButton); + + expect(screen.queryByText("Users")).not.toBeInTheDocument(); + expect(screen.getByTitle("Show usage details")).toBeInTheDocument(); + }); + + it("should restore from minimized when user clicks restore button", async () => { + const user = userEvent.setup(); + render(); + + await screen.findByText("Usage"); + + await user.click(screen.getByTitle("Minimize")); + await user.click(screen.getByTitle("Show usage details")); + + expect(screen.getByText("Usage")).toBeInTheDocument(); + expect(screen.getByText("Users")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/usage_indicator.tsx b/ui/litellm-dashboard/src/components/UsageIndicator.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/usage_indicator.tsx rename to ui/litellm-dashboard/src/components/UsageIndicator.tsx index ed5e8e07555..3976e4d3d6f 100644 --- a/ui/litellm-dashboard/src/components/usage_indicator.tsx +++ b/ui/litellm-dashboard/src/components/UsageIndicator.tsx @@ -1,3 +1,4 @@ +import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator"; import { Badge } from "@tremor/react"; import { AlertTriangle, ChevronDown, ChevronUp, Loader2, Minus, TrendingUp, UserCheck, Users } from "lucide-react"; import { useEffect, useState } from "react"; @@ -23,7 +24,7 @@ interface UsageData { } export default function UsageIndicator({ accessToken, width = 220 }: UsageIndicatorProps) { - const position = "bottom-left"; + const disableUsageIndicator = useDisableUsageIndicator(); const [isExpanded, setIsExpanded] = useState(false); const [isMinimized, setIsMinimized] = useState(false); const [data, setData] = useState(null); @@ -541,8 +542,8 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica ); }; - // Don't render anything if no access token or if both total_users and total_teams are null - if (!accessToken || (data?.total_users === null && data?.total_teams === null)) { + // Don't render anything if disabled, no access token, or if both total_users and total_teams are null + if (disableUsageIndicator || !accessToken || (data?.total_users === null && data?.total_teams === null)) { return null; } diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index b26590989d2..e35d70a65bb 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -29,9 +29,9 @@ import type { MenuProps } from "antd"; import { ConfigProvider, Layout, Menu } from "antd"; import { useMemo } from "react"; import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "../utils/roles"; -import type { Organization } from "./networking"; -import UsageIndicator from "./usage_indicator"; import NewBadge from "./common_components/NewBadge"; +import type { Organization } from "./networking"; +import UsageIndicator from "./UsageIndicator"; const { Sider } = Layout; // Define the props type diff --git a/ui/litellm-dashboard/src/components/usage_indicator.test.tsx b/ui/litellm-dashboard/src/components/usage_indicator.test.tsx deleted file mode 100644 index 5d82d5f1540..00000000000 --- a/ui/litellm-dashboard/src/components/usage_indicator.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from "react"; -import { describe, it, expect, vi } from "vitest"; -import { render, waitFor } from "@testing-library/react"; -import UsageIndicator from "./usage_indicator"; - -vi.mock("./networking", () => { - return { - getRemainingUsers: vi.fn(), - }; -}); - -import { getRemainingUsers } from "./networking"; - -describe("UsageIndicator", () => { - it("does not show Near limit when users usage is below 80% (1/100 -> 1%)", async () => { - (getRemainingUsers as unknown as ReturnType).mockResolvedValue({ - total_users: 100, - total_users_used: 1, - total_users_remaining: 99, - total_teams: null, - total_teams_used: 0, - total_teams_remaining: null, - }); - - const { queryByText, findByText } = render(); - - await findByText("Usage"); - - expect(queryByText("Near limit")).toBeNull(); - }); - - it("handles null totals shape by rendering nothing", async () => { - (getRemainingUsers as unknown as ReturnType).mockResolvedValue({ - total_users: null, - total_teams: null, - total_users_used: 520, - total_teams_used: 4, - total_teams_remaining: null, - total_users_remaining: null, - }); - - const { container } = render(); - - await waitFor(() => { - expect(container.firstChild).toBeNull(); - }); - }); - - it("shows Near limit for Teams at 80% usage (4/5)", async () => { - (getRemainingUsers as unknown as ReturnType).mockResolvedValue({ - total_users: null, - total_users_used: 0, - total_users_remaining: null, - total_teams: 5, - total_teams_used: 4, - total_teams_remaining: 1, - }); - - const { findByText, getByText } = render(); - - await findByText("Usage"); - - // Teams section should show Near limit indicator - expect(getByText("Teams")).toBeTruthy(); - expect(getByText("Near limit")).toBeTruthy(); - }); - - it("shows Over limit for Users when usage exceeds 100% (105/100)", async () => { - (getRemainingUsers as unknown as ReturnType).mockResolvedValue({ - total_users: 100, - total_users_used: 105, - total_users_remaining: -5, - total_teams: null, - total_teams_used: 0, - total_teams_remaining: null, - }); - - const { findByText, getByText } = render(); - - await findByText("Usage"); - - // Users section should show Over limit indicator - expect(getByText("Users")).toBeTruthy(); - expect(getByText("Over limit")).toBeTruthy(); - }); -});