From 12d1873b44286f1bb9c1e07970958b5e134354b1 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Jul 2026 13:23:34 -0700 Subject: [PATCH 1/4] feat(ui): add enterprise license expiry banner to admin dashboard Surfaces a persistent, tiered banner under the dashboard navbar when an airgapped enterprise license is close to expiring: an amber, session-dismissible warning within 30 days, a non-dismissible red alert within 7 days, and a non-dismissible red banner once the date has passed. It reads the existing /health/license endpoint, so no backend change is needed, and is driven strictly by expiration_date; community and remote-validated instances that report no date show nothing. Shared day-count math is extracted to licenseUtils so the banner and the existing UsageIndicator widget stay in sync --- .../hooks/license/useLicenseInfo.ts | 15 +++ .../src/app/(dashboard)/layout.tsx | 2 + .../components/LicenseExpiryBanner.test.tsx | 90 ++++++++++++++++++ .../src/components/LicenseExpiryBanner.tsx | 92 +++++++++++++++++++ .../src/components/UsageIndicator.tsx | 12 +-- .../src/utils/licenseUtils.test.ts | 61 ++++++++++++ .../src/utils/licenseUtils.ts | 49 ++++++++++ 7 files changed, 310 insertions(+), 11 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts create mode 100644 ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx create mode 100644 ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx create mode 100644 ui/litellm-dashboard/src/utils/licenseUtils.test.ts create mode 100644 ui/litellm-dashboard/src/utils/licenseUtils.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts new file mode 100644 index 00000000000..f4574c36ef6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts @@ -0,0 +1,15 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { getLicenseInfo, LicenseInfo } from "@/components/networking"; +import { createQueryKeys } from "../common/queryKeysFactory"; + +const licenseInfoKeys = createQueryKeys("licenseInfo"); + +export const useLicenseInfo = (accessToken: string | null | undefined): UseQueryResult => { + return useQuery({ + queryKey: licenseInfoKeys.detail("license"), + queryFn: () => getLicenseInfo(accessToken!), + enabled: Boolean(accessToken), + staleTime: 5 * 60 * 1000, + retry: false, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 09951dc1923..c84209b80cf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -8,6 +8,7 @@ import { useAuth } from "@/contexts/AuthContext"; import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { DebugWarningBanner } from "@/components/DebugWarningBanner"; +import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner"; import { MIGRATED_PAGES, migratedHref, legacyPageHref, legacyKeyForPathname } from "@/utils/migratedPages"; import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext"; import { createApiClient } from "@/lib/http/client"; @@ -116,6 +117,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) { onToggleSidebar={() => setSidebarCollapsed((v) => !v)} /> +
{mode !== "ai-gateway" ? (
diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx new file mode 100644 index 00000000000..d6b419ace7c --- /dev/null +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { LicenseExpiryBannerView } from "./LicenseExpiryBanner"; +import { LicenseInfo } from "./networking"; + +const daysFromNow = (n: number): string => { + const date = new Date(); + date.setUTCDate(date.getUTCDate() + n); + return date.toISOString().slice(0, 10); +}; + +const licenseWith = (expiration_date: string | null): LicenseInfo => ({ + has_license: expiration_date !== null, + license_type: expiration_date !== null ? "enterprise" : "community", + expiration_date, + allowed_features: [], + limits: { max_users: null, max_teams: null }, +}); + +describe("LicenseExpiryBannerView", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + afterEach(() => { + sessionStorage.clear(); + }); + + it("renders nothing when there is no license info", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when expiration_date is null (community or remote-validated)", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when expiry is more than 30 days out", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows a dismissible amber warning within 30 days", () => { + const { container } = render(); + expect(screen.getByText(/expires in 20 days/)).toBeInTheDocument(); + expect(container.querySelector(".ant-alert-warning")).toBeInTheDocument(); + expect(screen.queryByRole("button")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "sales@berri.ai" })).toHaveAttribute("href", "mailto:sales@berri.ai"); + }); + + it("shows a non-dismissible red critical alert within 7 days", () => { + const { container } = render(); + expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument(); + expect(container.querySelector(".ant-alert-error")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("says 'expires today' on the expiration day", () => { + render(); + expect(screen.getByText(/expires today/)).toBeInTheDocument(); + }); + + it("shows a non-dismissible red expired alert stating features are disabled", () => { + const { container } = render(); + expect(screen.getByText(/expired on/)).toBeInTheDocument(); + expect(screen.getByText(/features are now disabled/i)).toBeInTheDocument(); + expect(container.querySelector(".ant-alert-error")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("hides the warning after dismissal and stays hidden within the session", () => { + const expiration = daysFromNow(20); + const { unmount } = render(); + fireEvent.click(screen.getByRole("button")); + expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument(); + + unmount(); + render(); + expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument(); + }); + + it("still shows a critical alert even when its date was previously dismissed", () => { + const expiration = daysFromNow(5); + sessionStorage.setItem(`litellm:licenseExpiryBannerDismissed:${expiration}`, "true"); + render(); + expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx new file mode 100644 index 00000000000..e5b8a65168a --- /dev/null +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx @@ -0,0 +1,92 @@ +"use client"; + +import React, { useState } from "react"; +import { Alert } from "antd"; +import { LicenseInfo } from "@/components/networking"; +import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo"; +import { formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "@/utils/licenseUtils"; + +const DISMISS_KEY_PREFIX = "litellm:licenseExpiryBannerDismissed:"; +const SALES_EMAIL = "sales@berri.ai"; + +const salesLink = {SALES_EMAIL}; + +interface LicenseExpiryBannerProps { + accessToken: string | null; +} + +interface LicenseExpiryBannerViewProps { + licenseInfo: LicenseInfo | null; +} + +const describeCountdown = (days: number): string => { + if (days <= 0) { + return "expires today"; + } + if (days === 1) { + return "expires in 1 day"; + } + return `expires in ${days} days`; +}; + +export const LicenseExpiryBannerView: React.FC = ({ licenseInfo }) => { + const [locallyDismissed, setLocallyDismissed] = useState(false); + + const expirationDate = licenseInfo?.expiration_date ?? null; + const tier = getLicenseExpiryTier(expirationDate); + const days = getDaysUntilExpiration(expirationDate); + + if (expirationDate === null || tier === "none" || days === null) { + return null; + } + + const isDismissible = tier === "warning"; + const dismissKey = `${DISMISS_KEY_PREFIX}${expirationDate}`; + const previouslyDismissed = + isDismissible && typeof window !== "undefined" ? sessionStorage.getItem(dismissKey) === "true" : false; + + if (isDismissible && (locallyDismissed || previouslyDismissed)) { + return null; + } + + const formattedDate = formatExpiryDate(expirationDate); + + const message = + tier === "expired" + ? `Your LiteLLM Enterprise license expired on ${formattedDate}` + : `Your LiteLLM Enterprise license ${describeCountdown(days)} (${formattedDate})`; + + const description = + tier === "expired" ? ( + <>Enterprise features are now disabled. Reach out to {salesLink} to restore access + ) : tier === "critical" ? ( + <>Renew now to avoid losing enterprise features. Reach out to {salesLink} + ) : ( + <>Renew before it lapses to keep enterprise features. Reach out to {salesLink} + ); + + const handleClose = () => { + if (typeof window !== "undefined") { + sessionStorage.setItem(dismissKey, "true"); + } + setLocallyDismissed(true); + }; + + return ( + + ); +}; + +export const LicenseExpiryBanner: React.FC = ({ accessToken }) => { + const { data } = useLicenseInfo(accessToken); + return ; +}; diff --git a/ui/litellm-dashboard/src/components/UsageIndicator.tsx b/ui/litellm-dashboard/src/components/UsageIndicator.tsx index 030df228de0..8165f8eb6f6 100644 --- a/ui/litellm-dashboard/src/components/UsageIndicator.tsx +++ b/ui/litellm-dashboard/src/components/UsageIndicator.tsx @@ -15,6 +15,7 @@ import { useEffect, useState } from "react"; import { getRemainingUsers, getLicenseInfo, LicenseInfo } from "./networking"; import { cn } from "@/lib/cva.config"; +import { getDaysUntilExpiration } from "@/utils/licenseUtils"; interface UsageIndicatorProps { accessToken: string | null; @@ -30,17 +31,6 @@ interface UsageData { total_teams_remaining: number | null; } -// Calculate days until expiration -const getDaysUntilExpiration = (expirationDate: string | null): number | null => { - if (!expirationDate) return null; - const expDate = new Date(expirationDate + "T00:00:00Z"); // Force UTC midnight - const now = new Date(); - now.setHours(0, 0, 0, 0); // Normalize to local midnight - const diffTime = expDate.getTime() - now.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - return diffDays; -}; - // Format expiration for display const formatExpirationDisplay = (daysRemaining: number | null): string => { if (daysRemaining === null) return "No expiration"; diff --git a/ui/litellm-dashboard/src/utils/licenseUtils.test.ts b/ui/litellm-dashboard/src/utils/licenseUtils.test.ts new file mode 100644 index 00000000000..717b8f0d90d --- /dev/null +++ b/ui/litellm-dashboard/src/utils/licenseUtils.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { type LicenseExpiryTier, formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "./licenseUtils"; + +const NOW = new Date("2026-07-08T00:00:00Z"); + +describe("getDaysUntilExpiration", () => { + it("returns null for a null expiration", () => { + expect(getDaysUntilExpiration(null, NOW)).toBeNull(); + }); + + it("returns null for an unparseable date", () => { + expect(getDaysUntilExpiration("not-a-date", NOW)).toBeNull(); + }); + + it("returns 0 for an expiration on the current UTC day", () => { + expect(getDaysUntilExpiration("2026-07-08", NOW)).toBe(0); + }); + + it("returns a positive count for future dates", () => { + expect(getDaysUntilExpiration("2026-07-15", NOW)).toBe(7); + expect(getDaysUntilExpiration("2026-08-07", NOW)).toBe(30); + }); + + it("returns a negative count for a past date", () => { + expect(getDaysUntilExpiration("2026-07-07", NOW)).toBe(-1); + }); + + it("is timezone-independent within a UTC day", () => { + expect(getDaysUntilExpiration("2026-08-07", new Date("2026-07-08T00:00:01Z"))).toBe(30); + expect(getDaysUntilExpiration("2026-08-07", new Date("2026-07-08T23:59:59Z"))).toBe(30); + }); +}); + +describe("getLicenseExpiryTier", () => { + const cases: Array<[string | null, LicenseExpiryTier]> = [ + [null, "none"], + ["not-a-date", "none"], + ["2026-08-08", "none"], + ["2026-08-07", "warning"], + ["2026-07-16", "warning"], + ["2026-07-15", "critical"], + ["2026-07-09", "critical"], + ["2026-07-08", "critical"], + ["2026-07-07", "expired"], + ["2026-01-01", "expired"], + ]; + + it.each(cases)("classifies %s as %s", (date, expected) => { + expect(getLicenseExpiryTier(date, NOW)).toBe(expected); + }); +}); + +describe("formatExpiryDate", () => { + it("formats an ISO date as a human-readable UTC date", () => { + expect(formatExpiryDate("2026-07-31")).toBe("Jul 31, 2026"); + }); + + it("returns the input unchanged when unparseable", () => { + expect(formatExpiryDate("bogus")).toBe("bogus"); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/licenseUtils.ts b/ui/litellm-dashboard/src/utils/licenseUtils.ts new file mode 100644 index 00000000000..b2681664c56 --- /dev/null +++ b/ui/litellm-dashboard/src/utils/licenseUtils.ts @@ -0,0 +1,49 @@ +export type LicenseExpiryTier = "none" | "warning" | "critical" | "expired"; + +export const LICENSE_EXPIRY_WARNING_DAYS = 30; +export const LICENSE_EXPIRY_CRITICAL_DAYS = 7; + +export const getDaysUntilExpiration = (expirationDate: string | null, now: Date = new Date()): number | null => { + if (!expirationDate) { + return null; + } + + const expiration = new Date(`${expirationDate}T00:00:00Z`); + if (Number.isNaN(expiration.getTime())) { + return null; + } + + const nowUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const diffMs = expiration.getTime() - nowUtcMidnight; + return Math.ceil(diffMs / (1000 * 60 * 60 * 24)); +}; + +export const getLicenseExpiryTier = (expirationDate: string | null, now: Date = new Date()): LicenseExpiryTier => { + const days = getDaysUntilExpiration(expirationDate, now); + if (days === null) { + return "none"; + } + if (days < 0) { + return "expired"; + } + if (days <= LICENSE_EXPIRY_CRITICAL_DAYS) { + return "critical"; + } + if (days <= LICENSE_EXPIRY_WARNING_DAYS) { + return "warning"; + } + return "none"; +}; + +export const formatExpiryDate = (expirationDate: string): string => { + const date = new Date(`${expirationDate}T00:00:00Z`); + if (Number.isNaN(date.getTime())) { + return expirationDate; + } + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", + }); +}; From 7b2742777d31d2c7af6eeb5e1d3a3770d3570c81 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Jul 2026 14:07:29 -0700 Subject: [PATCH 2/4] refactor(ui): dedupe /health/license fetch via shared useLicenseInfo hook UsageIndicator was fetching /health/license through its own useEffect while the new expiry banner fetches the same endpoint via useLicenseInfo, so an admin with the usage widget open made two identical calls per page load. Point UsageIndicator at useLicenseInfo too; both callers now share one React Query cache entry, collapsing it back to a single request. The null/error semantics are preserved (data ?? null matches the previous catch-to-null), and license errors never fed the widget's error state before either --- .../src/components/UsageIndicator.test.tsx | 30 +++++++++++-------- .../src/components/UsageIndicator.tsx | 12 ++++---- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx b/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx index 8c7c15bc5a5..ad27fbcd74f 100644 --- a/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx +++ b/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx @@ -2,6 +2,7 @@ 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", () => ({ @@ -17,6 +18,11 @@ import { getRemainingUsers } from "./networking"; const mockGetRemainingUsers = vi.mocked(getRemainingUsers); +const renderWithClient = (ui: React.ReactElement) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +}; + const DEFAULT_USAGE_DATA = { total_users: 100, total_users_used: 1, @@ -33,7 +39,7 @@ describe("UsageIndicator", () => { }); it("should render when given access token and usage data loads", async () => { - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -41,7 +47,7 @@ describe("UsageIndicator", () => { }); it("should not show Near limit when users usage is below 80% (1/100 -> 1%)", async () => { - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -58,7 +64,7 @@ describe("UsageIndicator", () => { total_users_remaining: null, }); - render(); + renderWithClient(); await waitFor(() => { expect(screen.queryByText("Usage")).not.toBeInTheDocument(); @@ -76,7 +82,7 @@ describe("UsageIndicator", () => { total_teams_remaining: 1, }); - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -94,7 +100,7 @@ describe("UsageIndicator", () => { total_teams_remaining: null, }); - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -112,7 +118,7 @@ describe("UsageIndicator", () => { total_teams_remaining: -2, }); - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -121,7 +127,7 @@ describe("UsageIndicator", () => { }); it("should render nothing when accessToken is null", () => { - render(); + renderWithClient(); expect(mockGetRemainingUsers).not.toHaveBeenCalled(); expect(screen.queryByText("Usage")).not.toBeInTheDocument(); @@ -131,7 +137,7 @@ describe("UsageIndicator", () => { const { useDisableUsageIndicator } = await import("@/app/(dashboard)/hooks/useDisableUsageIndicator"); (useDisableUsageIndicator as ReturnType).mockReturnValue(true); - render(); + renderWithClient(); await waitFor(() => { expect(screen.queryByText("Usage")).not.toBeInTheDocument(); @@ -143,7 +149,7 @@ describe("UsageIndicator", () => { it("should show Loading while fetching", () => { mockGetRemainingUsers.mockImplementation(() => new Promise(() => {})); - render(); + renderWithClient(); expect(screen.getByText("Loading...")).toBeInTheDocument(); }); @@ -152,7 +158,7 @@ describe("UsageIndicator", () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); mockGetRemainingUsers.mockRejectedValue(new Error("Network error")); - render(); + renderWithClient(); expect(await screen.findByText("Failed to load usage data")).toBeInTheDocument(); @@ -161,7 +167,7 @@ describe("UsageIndicator", () => { it("should minimize when user clicks minimize button", async () => { const user = userEvent.setup(); - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -174,7 +180,7 @@ describe("UsageIndicator", () => { it("should restore from minimized when user clicks restore button", async () => { const user = userEvent.setup(); - render(); + renderWithClient(); await screen.findByText("Usage"); diff --git a/ui/litellm-dashboard/src/components/UsageIndicator.tsx b/ui/litellm-dashboard/src/components/UsageIndicator.tsx index 8165f8eb6f6..6e7b5e9ec60 100644 --- a/ui/litellm-dashboard/src/components/UsageIndicator.tsx +++ b/ui/litellm-dashboard/src/components/UsageIndicator.tsx @@ -12,10 +12,11 @@ import { Users, } from "lucide-react"; import { useEffect, useState } from "react"; -import { getRemainingUsers, getLicenseInfo, LicenseInfo } from "./networking"; +import { getRemainingUsers } from "./networking"; import { cn } from "@/lib/cva.config"; import { getDaysUntilExpiration } from "@/utils/licenseUtils"; +import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo"; interface UsageIndicatorProps { accessToken: string | null; @@ -48,10 +49,11 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica const [isExpanded, setIsExpanded] = useState(false); const [isMinimized, setIsMinimized] = useState(false); const [data, setData] = useState(null); - const [licenseInfo, setLicenseInfo] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const licenseInfo = useLicenseInfo(accessToken).data ?? null; + useEffect(() => { const fetchData = async () => { if (!accessToken) return; @@ -60,12 +62,8 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica setError(null); try { - const [usageResult, licenseResult] = await Promise.all([ - getRemainingUsers(accessToken), - getLicenseInfo(accessToken).catch(() => null), // Don't fail if license endpoint unavailable - ]); + const usageResult = await getRemainingUsers(accessToken); setData(usageResult); - setLicenseInfo(licenseResult); } catch (err) { console.error("Failed to fetch usage data:", err); setError("Failed to load usage data"); From 34aedc40c6467e8a81dbd18e8df71d20cb6bcd96 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Jul 2026 14:50:27 -0700 Subject: [PATCH 3/4] test(ui): mock LicenseExpiryBanner in the dashboard layout test The layout test renders DashboardShell without a QueryClientProvider and mocks DebugWarningBanner to null for exactly that reason. The new LicenseExpiryBanner also uses a React Query hook, so it needs the same treatment; without it the test threw "No QueryClient set". Runtime is unaffected: the app mounts a QueryClientProvider above the layout (DebugWarningBanner already relies on it) --- ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index af68d9f87e9..7573ddb5a0f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -21,6 +21,10 @@ vi.mock("@/components/DebugWarningBanner", () => ({ DebugWarningBanner: () => null, })); +vi.mock("@/components/LicenseExpiryBanner", () => ({ + LicenseExpiryBanner: () => null, +})); + vi.mock("@/contexts/ThemeContext", () => ({ ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); From 641396762ac8e363325ae1e177e8079e4edec9e4 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Jul 2026 15:17:13 -0700 Subject: [PATCH 4/4] refactor(ui): conform license banner to new eslint rules Staging recently added the local eslint rules no-large-inline-object-arg and no-long-condition-chain and tightened no-nested-ternary to an error. After merging staging, the license-banner code tripped them: the banner's tiered description was a nested ternary (now an error), and two option objects were passed inline (adding budget debt). Extract the description into an early-return helper, and hoist the useQuery options and the date-format options into named constants. No behavior change; keeps the inline-object-arg count at the committed baseline rather than bumping it --- .../hooks/license/useLicenseInfo.ts | 5 +++-- .../src/components/LicenseExpiryBanner.tsx | 19 +++++++++++-------- .../src/utils/licenseUtils.ts | 14 ++++++++------ 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts index f4574c36ef6..3ea0bd20e40 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts @@ -5,11 +5,12 @@ import { createQueryKeys } from "../common/queryKeysFactory"; const licenseInfoKeys = createQueryKeys("licenseInfo"); export const useLicenseInfo = (accessToken: string | null | undefined): UseQueryResult => { - return useQuery({ + const options = { queryKey: licenseInfoKeys.detail("license"), queryFn: () => getLicenseInfo(accessToken!), enabled: Boolean(accessToken), staleTime: 5 * 60 * 1000, retry: false, - }); + }; + return useQuery(options); }; diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx index e5b8a65168a..c3b20b5fac0 100644 --- a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx @@ -29,6 +29,16 @@ const describeCountdown = (days: number): string => { return `expires in ${days} days`; }; +const expiryDescription = (tier: "warning" | "critical" | "expired"): React.ReactNode => { + if (tier === "expired") { + return <>Enterprise features are now disabled. Reach out to {salesLink} to restore access; + } + if (tier === "critical") { + return <>Renew now to avoid losing enterprise features. Reach out to {salesLink}; + } + return <>Renew before it lapses to keep enterprise features. Reach out to {salesLink}; +}; + export const LicenseExpiryBannerView: React.FC = ({ licenseInfo }) => { const [locallyDismissed, setLocallyDismissed] = useState(false); @@ -56,14 +66,7 @@ export const LicenseExpiryBannerView: React.FC = ( ? `Your LiteLLM Enterprise license expired on ${formattedDate}` : `Your LiteLLM Enterprise license ${describeCountdown(days)} (${formattedDate})`; - const description = - tier === "expired" ? ( - <>Enterprise features are now disabled. Reach out to {salesLink} to restore access - ) : tier === "critical" ? ( - <>Renew now to avoid losing enterprise features. Reach out to {salesLink} - ) : ( - <>Renew before it lapses to keep enterprise features. Reach out to {salesLink} - ); + const description = expiryDescription(tier); const handleClose = () => { if (typeof window !== "undefined") { diff --git a/ui/litellm-dashboard/src/utils/licenseUtils.ts b/ui/litellm-dashboard/src/utils/licenseUtils.ts index b2681664c56..57acad85508 100644 --- a/ui/litellm-dashboard/src/utils/licenseUtils.ts +++ b/ui/litellm-dashboard/src/utils/licenseUtils.ts @@ -35,15 +35,17 @@ export const getLicenseExpiryTier = (expirationDate: string | null, now: Date = return "none"; }; +const EXPIRY_DATE_FORMAT: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", +}; + export const formatExpiryDate = (expirationDate: string): string => { const date = new Date(`${expirationDate}T00:00:00Z`); if (Number.isNaN(date.getTime())) { return expirationDate; } - return date.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - timeZone: "UTC", - }); + return date.toLocaleDateString("en-US", EXPIRY_DATE_FORMAT); };