mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge pull request #33482 from BerriAI/litellm_/laughing-herschel-d4f735
chore(ui): remove unmounted UsageIndicator and the Hide Usage Indicator flag
This commit is contained in:
commit
39c01fe104
10 changed files with 1 additions and 1163 deletions
|
|
@ -1655,17 +1655,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/UsageIndicator.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 4
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/static-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/activity_metrics.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,190 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
|||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator";
|
||||
import {
|
||||
emitLocalStorageChange,
|
||||
getLocalStorageItem,
|
||||
|
|
@ -72,7 +71,6 @@ interface UserDropdownProps {
|
|||
const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar", collapsed = false }) => {
|
||||
const { userId, userEmail, userRole, premiumUser } = useAuthorized();
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
const disableUsageIndicator = useDisableUsageIndicator();
|
||||
const disableBlogPosts = useDisableBlogPosts();
|
||||
const disableBouncingIcon = useDisableBouncingIcon();
|
||||
const [disableShowNewBadge, setDisableShowNewBadge] = useState(false);
|
||||
|
|
@ -165,23 +163,6 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
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 style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide Blog Posts</Text>
|
||||
<Switch
|
||||
|
|
|
|||
|
|
@ -37,10 +37,6 @@ vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({
|
|||
useDisableShowPrompts: () => mockUseDisableShowPromptsImpl(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableUsageIndicator", () => ({
|
||||
useDisableUsageIndicator: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableBlogPosts", () => ({
|
||||
useDisableBlogPosts: () => false,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts
|
|||
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
|
||||
import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator";
|
||||
import { emitLocalStorageChange, removeLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
|
||||
import { navAccountDisplayName } from "@/components/Navbar/navDisplayName";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
|
|
@ -86,7 +85,6 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
const version = healthData?.litellm_version;
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
const disableUsageIndicator = useDisableUsageIndicator();
|
||||
const disableBlogPosts = useDisableBlogPosts();
|
||||
const disableBouncingIcon = useDisableBouncingIcon();
|
||||
const disableShowNewBadge = useDisableShowNewBadge();
|
||||
|
|
@ -115,13 +113,6 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
checked: disableShowPrompts,
|
||||
onCheckedChange: (checked: boolean) => setFlag("disableShowPrompts", checked),
|
||||
},
|
||||
{
|
||||
key: "disableUsageIndicator",
|
||||
label: "Hide Usage Indicator",
|
||||
ariaLabel: "Toggle hide usage indicator",
|
||||
checked: disableUsageIndicator,
|
||||
onCheckedChange: (checked: boolean) => setFlag("disableUsageIndicator", checked),
|
||||
},
|
||||
{
|
||||
key: "disableBlogPosts",
|
||||
label: "Hide Blog Posts",
|
||||
|
|
|
|||
|
|
@ -8,10 +8,6 @@ import type { LicenseInfo } from "./networking";
|
|||
|
||||
vi.mock("./networking", () => ({ getRemainingUsers: vi.fn() }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableUsageIndicator", () => ({
|
||||
useDisableUsageIndicator: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/license/useLicenseInfo", () => ({
|
||||
useLicenseInfo: vi.fn(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator";
|
||||
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
|
||||
import { formatExpirationStatus } from "@/utils/licenseUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -69,7 +68,6 @@ const buildMeters = (data: RemainingUsage | null): MeterData[] => {
|
|||
* design's Spend / API-request meters are intentionally omitted.
|
||||
*/
|
||||
export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail }: SidebarUsageCardProps) {
|
||||
const disableUsageIndicator = useDisableUsageIndicator();
|
||||
const licenseInfo = useLicenseInfo(accessToken).data ?? null;
|
||||
const { data: usageData, isLoading } = useQuery(remainingUsersQuery(accessToken));
|
||||
const data = usageData ?? null;
|
||||
|
|
@ -77,7 +75,7 @@ export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail
|
|||
const hasData = data !== null && (data.total_users !== null || data.total_teams !== null);
|
||||
const noUsableData = !isLoading && !hasData;
|
||||
const noLicensedUsage = !licenseInfo?.has_license || noUsableData;
|
||||
if (disableUsageIndicator || !accessToken || noLicensedUsage) {
|
||||
if (!accessToken || noLicensedUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,221 +0,0 @@
|
|||
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import UsageIndicator from "./UsageIndicator";
|
||||
|
||||
vi.mock("./networking", () => ({
|
||||
getRemainingUsers: vi.fn(),
|
||||
getLicenseInfo: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableUsageIndicator", () => ({
|
||||
useDisableUsageIndicator: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
import { getLicenseInfo, getRemainingUsers } from "./networking";
|
||||
import type { LicenseInfo } from "./networking";
|
||||
|
||||
const mockGetRemainingUsers = vi.mocked(getRemainingUsers);
|
||||
const mockGetLicenseInfo = vi.mocked(getLicenseInfo);
|
||||
|
||||
const licenseWithExpiry = (expiration_date: string): LicenseInfo => ({
|
||||
has_license: true,
|
||||
license_type: "enterprise",
|
||||
expiration_date,
|
||||
allowed_features: [],
|
||||
limits: { max_users: null, max_teams: null },
|
||||
});
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
};
|
||||
|
||||
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);
|
||||
mockGetLicenseInfo.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("should render when given access token and usage data loads", async () => {
|
||||
renderWithClient(<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 () => {
|
||||
renderWithClient(<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,
|
||||
});
|
||||
|
||||
renderWithClient(<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,
|
||||
});
|
||||
|
||||
renderWithClient(<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,
|
||||
});
|
||||
|
||||
renderWithClient(<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,
|
||||
});
|
||||
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
expect(screen.getByText("Teams")).toBeInTheDocument();
|
||||
expect(screen.getByText("Over limit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the exact license expiration date instead of time remaining", async () => {
|
||||
mockGetLicenseInfo.mockResolvedValue(licenseWithExpiry("2099-12-31"));
|
||||
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
expect(await screen.findByText("Expires Dec 31, 2099")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/(day|days|month|months) remaining/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the exact date when the license is expired", async () => {
|
||||
mockGetLicenseInfo.mockResolvedValue(licenseWithExpiry("2020-01-01"));
|
||||
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
expect(await screen.findByText("Expired Jan 1, 2020")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render nothing when accessToken is null", () => {
|
||||
renderWithClient(<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);
|
||||
|
||||
renderWithClient(<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(() => {}));
|
||||
|
||||
renderWithClient(<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"));
|
||||
|
||||
renderWithClient(<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();
|
||||
renderWithClient(<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();
|
||||
renderWithClient(<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,669 +0,0 @@
|
|||
import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator";
|
||||
import { Badge } from "@tremor/react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Loader2,
|
||||
Minus,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getRemainingUsers } from "./networking";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { formatExpirationStatus, getDaysUntilExpiration } from "@/utils/licenseUtils";
|
||||
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
|
||||
|
||||
interface UsageIndicatorProps {
|
||||
accessToken: string | null;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface UsageData {
|
||||
total_users: number | null;
|
||||
total_users_used: number;
|
||||
total_users_remaining: number | null;
|
||||
total_teams: number | null;
|
||||
total_teams_used: number;
|
||||
total_teams_remaining: number | null;
|
||||
}
|
||||
|
||||
export default function UsageIndicator({ accessToken, width = 220 }: UsageIndicatorProps) {
|
||||
const disableUsageIndicator = useDisableUsageIndicator();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [data, setData] = useState<UsageData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const licenseInfo = useLicenseInfo(accessToken).data ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const usageResult = await getRemainingUsers(accessToken);
|
||||
setData(usageResult);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch usage data:", err);
|
||||
setError("Failed to load usage data");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [accessToken]);
|
||||
|
||||
// Calculate license expiration metrics
|
||||
const daysUntilExpiration = licenseInfo?.expiration_date ? getDaysUntilExpiration(licenseInfo.expiration_date) : null;
|
||||
const isLicenseExpired = daysUntilExpiration !== null && daysUntilExpiration < 0;
|
||||
const isLicenseExpiringSoon = daysUntilExpiration !== null && daysUntilExpiration >= 0 && daysUntilExpiration < 30;
|
||||
|
||||
// Calculate derived values from data
|
||||
const getUsageMetrics = (data: UsageData | null) => {
|
||||
if (!data) {
|
||||
return {
|
||||
isOverLimit: false,
|
||||
isNearLimit: false,
|
||||
usagePercentage: 0,
|
||||
userMetrics: {
|
||||
isOverLimit: false,
|
||||
isNearLimit: false,
|
||||
usagePercentage: 0,
|
||||
},
|
||||
teamMetrics: {
|
||||
isOverLimit: false,
|
||||
isNearLimit: false,
|
||||
usagePercentage: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// User metrics
|
||||
const userUsagePercentage = data.total_users ? (data.total_users_used / data.total_users) * 100 : 0;
|
||||
const userIsOverLimit = userUsagePercentage > 100;
|
||||
const userIsNearLimit = userUsagePercentage >= 80 && userUsagePercentage <= 100;
|
||||
|
||||
// Team metrics
|
||||
const teamUsagePercentage = data.total_teams ? (data.total_teams_used / data.total_teams) * 100 : 0;
|
||||
const teamIsOverLimit = teamUsagePercentage > 100;
|
||||
const teamIsNearLimit = teamUsagePercentage >= 80 && teamUsagePercentage <= 100;
|
||||
|
||||
// Combined status (worst case scenario)
|
||||
const isOverLimit = userIsOverLimit || teamIsOverLimit;
|
||||
const isNearLimit = (userIsNearLimit || teamIsNearLimit) && !isOverLimit;
|
||||
const usagePercentage = Math.max(userUsagePercentage, teamUsagePercentage);
|
||||
|
||||
return {
|
||||
isOverLimit,
|
||||
isNearLimit,
|
||||
usagePercentage,
|
||||
userMetrics: {
|
||||
isOverLimit: userIsOverLimit,
|
||||
isNearLimit: userIsNearLimit,
|
||||
usagePercentage: userUsagePercentage,
|
||||
},
|
||||
teamMetrics: {
|
||||
isOverLimit: teamIsOverLimit,
|
||||
isNearLimit: teamIsNearLimit,
|
||||
usagePercentage: teamUsagePercentage,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const { isOverLimit, isNearLimit, usagePercentage, userMetrics, teamMetrics } = getUsageMetrics(data);
|
||||
|
||||
// Include license status in overall status
|
||||
const hasAnyIssue = isOverLimit || isNearLimit || isLicenseExpired || isLicenseExpiringSoon;
|
||||
const hasError = isOverLimit || isLicenseExpired;
|
||||
const hasWarning = (isNearLimit || isLicenseExpiringSoon) && !hasError;
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (hasError) return "red";
|
||||
if (hasWarning) return "yellow";
|
||||
return "green";
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (hasError) return <AlertTriangle className="h-3 w-3" />;
|
||||
if (hasWarning) return <TrendingUp className="h-3 w-3" />;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Minimized view - just a small restore button
|
||||
const MinimizedView = () => {
|
||||
return (
|
||||
<div className="px-3 py-1" style={{ maxWidth: `${width}px` }}>
|
||||
<button
|
||||
onClick={() => setIsMinimized(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-sm w-full",
|
||||
hasError && "text-red-400 hover:text-red-600",
|
||||
hasWarning && "text-yellow-500 hover:text-yellow-700",
|
||||
)}
|
||||
title="Show usage details"
|
||||
>
|
||||
<Users className="h-3 w-3 shrink-0" />
|
||||
{hasAnyIssue && <span className="shrink-0">{getStatusIcon()}</span>}
|
||||
<div className="flex items-center gap-1 truncate">
|
||||
{data && data.total_users !== null && (
|
||||
<span className="shrink-0">
|
||||
U:{data.total_users_used}/{data.total_users}
|
||||
</span>
|
||||
)}
|
||||
{data && data.total_teams !== null && (
|
||||
<span className="shrink-0">
|
||||
T:{data.total_teams_used}/{data.total_teams}
|
||||
</span>
|
||||
)}
|
||||
{licenseInfo?.expiration_date && daysUntilExpiration !== null && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isLicenseExpired && "text-red-500",
|
||||
isLicenseExpiringSoon && "text-yellow-500",
|
||||
)}
|
||||
>
|
||||
{daysUntilExpiration < 0 ? "Exp!" : `${daysUntilExpiration}d`}
|
||||
</span>
|
||||
)}
|
||||
{!data ||
|
||||
(data.total_users === null && data.total_teams === null && !licenseInfo && (
|
||||
<span className="truncate">Usage</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Sidebar/nav style component
|
||||
const NavStyleView = () => {
|
||||
if (isMinimized) {
|
||||
return <MinimizedView />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-gray-500" style={{ maxWidth: `${width}px` }}>
|
||||
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||||
<span className="text-sm truncate">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 px-3 py-2 text-gray-400 group"
|
||||
style={{ maxWidth: `${width}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<Users className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm truncate">{error || "No data"}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMinimized(true)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:bg-gray-100 rounded-sm transition-all shrink-0"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2 group" style={{ maxWidth: `${width}px` }}>
|
||||
{/* Main nav item style */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 text-left hover:bg-gray-50 rounded-md px-0 py-1 transition-colors flex-1 min-w-0",
|
||||
hasError && "text-red-600",
|
||||
hasWarning && "text-yellow-600",
|
||||
)}
|
||||
>
|
||||
<Users className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm font-medium truncate">Usage Status</span>
|
||||
{hasAnyIssue && (
|
||||
<Badge color={getStatusColor()} className="text-xs px-1.5 py-0.5 shrink-0">
|
||||
{getStatusIcon()}
|
||||
</Badge>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-3 w-3 text-gray-400 ml-auto shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 text-gray-400 ml-auto shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Minimize button */}
|
||||
<button
|
||||
onClick={() => setIsMinimized(true)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:bg-gray-100 rounded-sm transition-all ml-1 shrink-0"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus className="h-3 w-3 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded details - simple and compact */}
|
||||
{isExpanded && (
|
||||
<div className="mt-2 pl-7 text-xs text-gray-600 space-y-3">
|
||||
{/* License expiration section */}
|
||||
{licenseInfo?.has_license && licenseInfo.expiration_date && (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span className="font-medium">License</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs",
|
||||
isLicenseExpired && "text-red-600",
|
||||
isLicenseExpiringSoon && "text-yellow-600",
|
||||
)}
|
||||
>
|
||||
{isLicenseExpired ? (
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
) : isLicenseExpiringSoon ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : null}
|
||||
<span className="truncate">{formatExpirationStatus(licenseInfo.expiration_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users section */}
|
||||
{data.total_users !== null && (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
<span className="font-medium">
|
||||
{data.total_users_used}/{data.total_users}
|
||||
</span>
|
||||
<span className="text-gray-500">users</span>
|
||||
</div>
|
||||
|
||||
{/* User progress bar */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-1 mb-1">
|
||||
<div
|
||||
className={cn(
|
||||
"h-1 rounded-full transition-all duration-300",
|
||||
userMetrics.isOverLimit && "bg-red-500",
|
||||
userMetrics.isNearLimit && "bg-yellow-500",
|
||||
!userMetrics.isOverLimit && !userMetrics.isNearLimit && "bg-green-500",
|
||||
)}
|
||||
style={{ width: `${Math.min(userMetrics.usagePercentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(userMetrics.isOverLimit || userMetrics.isNearLimit) && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs",
|
||||
userMetrics.isOverLimit && "text-red-600",
|
||||
userMetrics.isNearLimit && "text-yellow-600",
|
||||
)}
|
||||
>
|
||||
{userMetrics.isOverLimit ? (
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
)}
|
||||
<span className="truncate">Users {userMetrics.isOverLimit ? "Over Limit" : "Near Limit"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Teams section */}
|
||||
{data.total_teams !== null && (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<UserCheck className="h-3 w-3" />
|
||||
<span className="font-medium">
|
||||
{data.total_teams_used}/{data.total_teams}
|
||||
</span>
|
||||
<span className="text-gray-500">teams</span>
|
||||
</div>
|
||||
|
||||
{/* Team progress bar */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-1 mb-1">
|
||||
<div
|
||||
className={cn(
|
||||
"h-1 rounded-full transition-all duration-300",
|
||||
teamMetrics.isOverLimit && "bg-red-500",
|
||||
teamMetrics.isNearLimit && "bg-yellow-500",
|
||||
!teamMetrics.isOverLimit && !teamMetrics.isNearLimit && "bg-green-500",
|
||||
)}
|
||||
style={{ width: `${Math.min(teamMetrics.usagePercentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(teamMetrics.isOverLimit || teamMetrics.isNearLimit) && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs",
|
||||
teamMetrics.isOverLimit && "text-red-600",
|
||||
teamMetrics.isNearLimit && "text-yellow-600",
|
||||
)}
|
||||
>
|
||||
{teamMetrics.isOverLimit ? (
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
)}
|
||||
<span className="truncate">Teams {teamMetrics.isOverLimit ? "Over Limit" : "Near Limit"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Optimized CardStyleView for 220px width
|
||||
const CardStyleView = () => {
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsMinimized(false)}
|
||||
className={cn(
|
||||
"bg-white border border-gray-200 rounded-lg shadow-xs p-3 hover:shadow-md transition-all w-full",
|
||||
)}
|
||||
title="Show usage details"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 shrink-0" />
|
||||
{hasAnyIssue && <span className="shrink-0">{getStatusIcon()}</span>}
|
||||
<div className="flex items-center gap-2 text-sm font-medium truncate">
|
||||
{data && data.total_users !== null && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 px-1.5 py-0.5 rounded-sm text-xs border",
|
||||
userMetrics.isOverLimit && "bg-red-50 text-red-700 border-red-200",
|
||||
userMetrics.isNearLimit && "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
!userMetrics.isOverLimit && !userMetrics.isNearLimit && "bg-gray-50 text-gray-700 border-gray-200",
|
||||
)}
|
||||
>
|
||||
U: {data.total_users_used}/{data.total_users}
|
||||
</span>
|
||||
)}
|
||||
{data && data.total_teams !== null && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 px-1.5 py-0.5 rounded-sm text-xs border",
|
||||
teamMetrics.isOverLimit && "bg-red-50 text-red-700 border-red-200",
|
||||
teamMetrics.isNearLimit && "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
!teamMetrics.isOverLimit && !teamMetrics.isNearLimit && "bg-gray-50 text-gray-700 border-gray-200",
|
||||
)}
|
||||
>
|
||||
T: {data.total_teams_used}/{data.total_teams}
|
||||
</span>
|
||||
)}
|
||||
{licenseInfo?.expiration_date && daysUntilExpiration !== null && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 px-1.5 py-0.5 rounded-sm text-xs border",
|
||||
isLicenseExpired && "bg-red-50 text-red-700 border-red-200",
|
||||
isLicenseExpiringSoon && "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
!isLicenseExpired && !isLicenseExpiringSoon && "bg-gray-50 text-gray-700 border-gray-200",
|
||||
)}
|
||||
>
|
||||
{daysUntilExpiration < 0 ? "Exp!" : `${daysUntilExpiration}d`}
|
||||
</span>
|
||||
)}
|
||||
{!data ||
|
||||
(data.total_users === null && data.total_teams === null && !licenseInfo && (
|
||||
<span className="truncate">Usage</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg shadow-xs p-4 w-full">
|
||||
<div className="flex items-center justify-center gap-2 py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm text-gray-500 truncate">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg shadow-xs p-4 group w-full">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-gray-500 truncate block">{error || "No data"}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMinimized(true)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-100 rounded-sm transition-all shrink-0"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus className="h-3 w-3 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("bg-white border rounded-lg shadow-xs p-3 transition-all duration-200 group w-full")}>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Users className="h-4 w-4 shrink-0" />
|
||||
<span className="font-medium text-sm truncate">Usage</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMinimized(true)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-100 rounded-sm transition-all shrink-0"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus className="h-3 w-3 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Compact stats optimized for 220px */}
|
||||
<div className="space-y-3 text-sm">
|
||||
{/* License expiration section */}
|
||||
{licenseInfo?.has_license && licenseInfo.expiration_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1 border rounded-md p-2",
|
||||
isLicenseExpired && "border-red-200 bg-red-50",
|
||||
isLicenseExpiringSoon && "border-yellow-200 bg-yellow-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 mb-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span className="font-medium">License</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 px-1.5 py-0.5 rounded-sm border",
|
||||
isLicenseExpired && "bg-red-50 text-red-700 border-red-200",
|
||||
isLicenseExpiringSoon && "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
!isLicenseExpired && !isLicenseExpiringSoon && "bg-gray-50 text-gray-600 border-gray-200",
|
||||
)}
|
||||
>
|
||||
{isLicenseExpired ? "Expired" : isLicenseExpiringSoon ? "Expiring soon" : "OK"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600 text-xs">Status:</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-right",
|
||||
isLicenseExpired && "text-red-600",
|
||||
isLicenseExpiringSoon && "text-yellow-600",
|
||||
)}
|
||||
>
|
||||
{formatExpirationStatus(licenseInfo.expiration_date)}
|
||||
</span>
|
||||
</div>
|
||||
{licenseInfo.license_type && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600 text-xs">Type:</span>
|
||||
<span className="font-medium text-right capitalize">{licenseInfo.license_type}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users section */}
|
||||
{data.total_users !== null && (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1 border rounded-md p-2",
|
||||
userMetrics.isOverLimit && "border-red-200 bg-red-50",
|
||||
userMetrics.isNearLimit && "border-yellow-200 bg-yellow-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 mb-1">
|
||||
<Users className="h-3 w-3" />
|
||||
<span className="font-medium">Users</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 px-1.5 py-0.5 rounded-sm border",
|
||||
userMetrics.isOverLimit && "bg-red-50 text-red-700 border-red-200",
|
||||
userMetrics.isNearLimit && "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
!userMetrics.isOverLimit && !userMetrics.isNearLimit && "bg-gray-50 text-gray-600 border-gray-200",
|
||||
)}
|
||||
>
|
||||
{userMetrics.isOverLimit ? "Over limit" : userMetrics.isNearLimit ? "Near limit" : "OK"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600 text-xs">Used:</span>
|
||||
<span className="font-medium text-right">
|
||||
{data.total_users_used}/{data.total_users}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600 text-xs">Remaining:</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-right",
|
||||
userMetrics.isOverLimit && "text-red-600",
|
||||
userMetrics.isNearLimit && "text-yellow-600",
|
||||
)}
|
||||
>
|
||||
{data.total_users_remaining}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600 text-xs">Usage:</span>
|
||||
<span className="font-medium text-right">{Math.round(userMetrics.usagePercentage)}%</span>
|
||||
</div>
|
||||
|
||||
{/* User progress bar */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 rounded-full transition-all duration-300",
|
||||
userMetrics.isOverLimit && "bg-red-500",
|
||||
userMetrics.isNearLimit && "bg-yellow-500",
|
||||
!userMetrics.isOverLimit && !userMetrics.isNearLimit && "bg-green-500",
|
||||
)}
|
||||
style={{ width: `${Math.min(userMetrics.usagePercentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Teams section */}
|
||||
{data.total_teams !== null && (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1 border rounded-md p-2",
|
||||
teamMetrics.isOverLimit && "border-red-200 bg-red-50",
|
||||
teamMetrics.isNearLimit && "border-yellow-200 bg-yellow-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 mb-1">
|
||||
<UserCheck className="h-3 w-3" />
|
||||
<span className="font-medium">Teams</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 px-1.5 py-0.5 rounded-sm border",
|
||||
teamMetrics.isOverLimit && "bg-red-50 text-red-700 border-red-200",
|
||||
teamMetrics.isNearLimit && "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
!teamMetrics.isOverLimit && !teamMetrics.isNearLimit && "bg-gray-50 text-gray-600 border-gray-200",
|
||||
)}
|
||||
>
|
||||
{teamMetrics.isOverLimit ? "Over limit" : teamMetrics.isNearLimit ? "Near limit" : "OK"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600 text-xs">Used:</span>
|
||||
<span className="font-medium text-right">
|
||||
{data.total_teams_used}/{data.total_teams}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600 text-xs">Remaining:</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-right",
|
||||
teamMetrics.isOverLimit && "text-red-600",
|
||||
teamMetrics.isNearLimit && "text-yellow-600",
|
||||
)}
|
||||
>
|
||||
{data.total_teams_remaining}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600 text-xs">Usage:</span>
|
||||
<span className="font-medium text-right">{Math.round(teamMetrics.usagePercentage)}%</span>
|
||||
</div>
|
||||
|
||||
{/* Team progress bar */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 rounded-full transition-all duration-300",
|
||||
teamMetrics.isOverLimit && "bg-red-500",
|
||||
teamMetrics.isNearLimit && "bg-yellow-500",
|
||||
!teamMetrics.isOverLimit && !teamMetrics.isNearLimit && "bg-green-500",
|
||||
)}
|
||||
style={{ width: `${Math.min(teamMetrics.usagePercentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Fixed positioning with proper spacing from edges
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 z-50" style={{ width: `${Math.min(width, 220)}px` }}>
|
||||
<CardStyleView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue