Merge pull request #20910 from BerriAI/litellm_ui_hide_usage_modal
[Feature] UI - Navbar: Option to hide Usage Popup
This commit is contained in:
commit
d050404393
@ -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: <KeyOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "3",
|
||||
page: "llm-playground",
|
||||
label: "Test Key",
|
||||
icon: <PlayCircleOutlined style={{ fontSize: 18 }} />,
|
||||
roles: rolesWithWriteAccess,
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
page: "models",
|
||||
label: "Models + Endpoints",
|
||||
icon: <BlockOutlined style={{ fontSize: 18 }} />,
|
||||
roles: rolesWithWriteAccess,
|
||||
},
|
||||
{
|
||||
key: "12",
|
||||
page: "new_usage",
|
||||
label: "Usage",
|
||||
icon: <BarChartOutlined style={{ fontSize: 18 }} />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
},
|
||||
{ key: "6", page: "teams", label: "Teams", icon: <TeamOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "17",
|
||||
page: "organizations",
|
||||
label: "Organizations",
|
||||
icon: <BankOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "5",
|
||||
page: "users",
|
||||
label: "Internal Users",
|
||||
icon: <UserOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{ key: "14", page: "api_ref", label: "API Reference", icon: <ApiOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "16",
|
||||
page: "model-hub-table",
|
||||
label: "Model Hub",
|
||||
icon: <AppstoreOutlined style={{ fontSize: 18 }} />,
|
||||
},
|
||||
{ key: "15", page: "logs", label: "Logs", icon: <LineChartOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "11",
|
||||
page: "guardrails",
|
||||
label: "Guardrails",
|
||||
icon: <SafetyOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "28",
|
||||
page: "policies",
|
||||
label: "Policies",
|
||||
icon: <AuditOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "26",
|
||||
page: "tools",
|
||||
label: "Tools",
|
||||
icon: <ToolOutlined style={{ fontSize: 18 }} />,
|
||||
children: [
|
||||
{ key: "18", page: "mcp-servers", label: "MCP Servers", icon: <ToolOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "21",
|
||||
page: "vector-stores",
|
||||
label: "Vector Stores",
|
||||
icon: <DatabaseOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "experimental",
|
||||
page: "experimental",
|
||||
label: "Experimental",
|
||||
icon: <ExperimentOutlined style={{ fontSize: 18 }} />,
|
||||
children: [
|
||||
{
|
||||
key: "9",
|
||||
page: "caching",
|
||||
label: "Caching",
|
||||
icon: <DatabaseOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "25",
|
||||
page: "prompts",
|
||||
label: "Prompts",
|
||||
icon: <FileTextOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "10",
|
||||
page: "budgets",
|
||||
label: "Budgets",
|
||||
icon: <BankOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "20",
|
||||
page: "transform-request",
|
||||
label: "API Playground",
|
||||
icon: <ApiOutlined style={{ fontSize: 18 }} />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
},
|
||||
{
|
||||
key: "19",
|
||||
page: "tag-management",
|
||||
label: "Tag Management",
|
||||
icon: <TagsOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "27",
|
||||
page: "claude-code-plugins",
|
||||
label: "Claude Code Plugins",
|
||||
icon: <ToolOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{ key: "4", page: "usage", label: "Old Usage", icon: <BarChartOutlined style={{ fontSize: 18 }} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
page: "settings",
|
||||
label: "Settings",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
children: [
|
||||
{
|
||||
key: "11",
|
||||
page: "general-settings",
|
||||
label: "Router Settings",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "8",
|
||||
page: "settings",
|
||||
label: "Logging & Alerts",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "13",
|
||||
page: "admin-panel",
|
||||
label: "Admin Settings",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "14",
|
||||
page: "ui-theme",
|
||||
label: "UI Theme",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
{ key: "1", page: "api-keys", label: "Virtual Keys", icon: <KeyOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "3",
|
||||
page: "llm-playground",
|
||||
label: "Test Key",
|
||||
icon: <PlayCircleOutlined style={{ fontSize: 18 }} />,
|
||||
roles: rolesWithWriteAccess,
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
page: "models",
|
||||
label: "Models + Endpoints",
|
||||
icon: <BlockOutlined style={{ fontSize: 18 }} />,
|
||||
roles: rolesWithWriteAccess,
|
||||
},
|
||||
{
|
||||
key: "12",
|
||||
page: "new_usage",
|
||||
label: "Usage",
|
||||
icon: <BarChartOutlined style={{ fontSize: 18 }} />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
},
|
||||
{ key: "6", page: "teams", label: "Teams", icon: <TeamOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "17",
|
||||
page: "organizations",
|
||||
label: "Organizations",
|
||||
icon: <BankOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "5",
|
||||
page: "users",
|
||||
label: "Internal Users",
|
||||
icon: <UserOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{ key: "14", page: "api_ref", label: "API Reference", icon: <ApiOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "16",
|
||||
page: "model-hub-table",
|
||||
label: "Model Hub",
|
||||
icon: <AppstoreOutlined style={{ fontSize: 18 }} />,
|
||||
},
|
||||
{ key: "15", page: "logs", label: "Logs", icon: <LineChartOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "11",
|
||||
page: "guardrails",
|
||||
label: "Guardrails",
|
||||
icon: <SafetyOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "28",
|
||||
page: "policies",
|
||||
label: "Policies",
|
||||
icon: <AuditOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "26",
|
||||
page: "tools",
|
||||
label: "Tools",
|
||||
icon: <ToolOutlined style={{ fontSize: 18 }} />,
|
||||
children: [
|
||||
{ key: "18", page: "mcp-servers", label: "MCP Servers", icon: <ToolOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "21",
|
||||
page: "vector-stores",
|
||||
label: "Vector Stores",
|
||||
icon: <DatabaseOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "experimental",
|
||||
page: "experimental",
|
||||
label: "Experimental",
|
||||
icon: <ExperimentOutlined style={{ fontSize: 18 }} />,
|
||||
children: [
|
||||
{
|
||||
key: "9",
|
||||
page: "caching",
|
||||
label: "Caching",
|
||||
icon: <DatabaseOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "25",
|
||||
page: "prompts",
|
||||
label: "Prompts",
|
||||
icon: <FileTextOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "10",
|
||||
page: "budgets",
|
||||
label: "Budgets",
|
||||
icon: <BankOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "20",
|
||||
page: "transform-request",
|
||||
label: "API Playground",
|
||||
icon: <ApiOutlined style={{ fontSize: 18 }} />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
},
|
||||
{
|
||||
key: "19",
|
||||
page: "tag-management",
|
||||
label: "Tag Management",
|
||||
icon: <TagsOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "27",
|
||||
page: "claude-code-plugins",
|
||||
label: "Claude Code Plugins",
|
||||
icon: <ToolOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{ key: "4", page: "usage", label: "Old Usage", icon: <BarChartOutlined style={{ fontSize: 18 }} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
page: "settings",
|
||||
label: "Settings",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
children: [
|
||||
{
|
||||
key: "11",
|
||||
page: "general-settings",
|
||||
label: "Router Settings",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "8",
|
||||
page: "settings",
|
||||
label: "Logging & Alerts",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "13",
|
||||
page: "admin-panel",
|
||||
label: "Admin Settings",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "14",
|
||||
page: "ui-theme",
|
||||
label: "UI Theme",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => {
|
||||
const router = useRouter();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
@ -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<UserDropdownProps> = ({ 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<UserDropdownProps> = ({ onLogout }) => {
|
||||
aria-label="Toggle hide all prompts"
|
||||
/>
|
||||
</Space>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide Usage Indicator</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={disableUsageIndicator}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
setLocalStorageItem("disableUsageIndicator", "true");
|
||||
emitLocalStorageChange("disableUsageIndicator");
|
||||
} else {
|
||||
removeLocalStorageItem("disableUsageIndicator");
|
||||
emitLocalStorageChange("disableUsageIndicator");
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle hide usage indicator"
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
);
|
||||
|
||||
|
||||
186
ui/litellm-dashboard/src/components/UsageIndicator.test.tsx
Normal file
186
ui/litellm-dashboard/src/components/UsageIndicator.test.tsx
Normal file
@ -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(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
expect(screen.getByText("Teams")).toBeInTheDocument();
|
||||
expect(screen.getByText("Over limit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render nothing when accessToken is null", () => {
|
||||
render(<UsageIndicator accessToken={null} width={220} />);
|
||||
|
||||
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<typeof vi.fn>).mockReturnValue(true);
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Usage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
(useDisableUsageIndicator as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("should show Loading while fetching", () => {
|
||||
mockGetRemainingUsers.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -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<UsageData | null>(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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<typeof vi.fn>).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(<UsageIndicator accessToken={"token"} width={220} />);
|
||||
|
||||
await findByText("Usage");
|
||||
|
||||
expect(queryByText("Near limit")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles null totals shape by rendering nothing", async () => {
|
||||
(getRemainingUsers as unknown as ReturnType<typeof vi.fn>).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(<UsageIndicator accessToken={"token"} width={220} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Near limit for Teams at 80% usage (4/5)", async () => {
|
||||
(getRemainingUsers as unknown as ReturnType<typeof vi.fn>).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(<UsageIndicator accessToken={"token"} width={220} />);
|
||||
|
||||
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<typeof vi.fn>).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(<UsageIndicator accessToken={"token"} width={220} />);
|
||||
|
||||
await findByText("Usage");
|
||||
|
||||
// Users section should show Over limit indicator
|
||||
expect(getByText("Users")).toBeTruthy();
|
||||
expect(getByText("Over limit")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user