mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
Merge pull request #32540 from BerriAI/litellm_/license-expiry-alert-b26348
feat(ui): add enterprise license expiry banner to admin dashboard
This commit is contained in:
commit
5b8bf5357a
9 changed files with 343 additions and 30 deletions
|
|
@ -0,0 +1,16 @@
|
|||
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<LicenseInfo | null> => {
|
||||
const options = {
|
||||
queryKey: licenseInfoKeys.detail("license"),
|
||||
queryFn: () => getLicenseInfo(accessToken!),
|
||||
enabled: Boolean(accessToken),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
};
|
||||
return useQuery<LicenseInfo | null>(options);
|
||||
};
|
||||
|
|
@ -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}</>,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
<DebugWarningBanner accessToken={accessToken} />
|
||||
<LicenseExpiryBanner accessToken={accessToken} />
|
||||
<div className="flex flex-1">
|
||||
{mode !== "ai-gateway" ? (
|
||||
<div className="flex-1 flex">
|
||||
|
|
|
|||
|
|
@ -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(<LicenseExpiryBannerView licenseInfo={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing when expiration_date is null (community or remote-validated)", () => {
|
||||
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(null)} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing when expiry is more than 30 days out", () => {
|
||||
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(40))} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows a dismissible amber warning within 30 days", () => {
|
||||
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(20))} />);
|
||||
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(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(5))} />);
|
||||
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(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(0))} />);
|
||||
expect(screen.getByText(/expires today/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a non-dismissible red expired alert stating features are disabled", () => {
|
||||
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(-3))} />);
|
||||
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(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
render(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
|
||||
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(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
|
||||
expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
95
ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx
Normal file
95
ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"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 = <a href={`mailto:${SALES_EMAIL}`}>{SALES_EMAIL}</a>;
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
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<LicenseExpiryBannerViewProps> = ({ 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 = expiryDescription(tier);
|
||||
|
||||
const handleClose = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem(dismissKey, "true");
|
||||
}
|
||||
setLocallyDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert
|
||||
message={message}
|
||||
description={description}
|
||||
type={tier === "warning" ? "warning" : "error"}
|
||||
showIcon
|
||||
banner
|
||||
closable={isDismissible}
|
||||
onClose={handleClose}
|
||||
style={{ marginBottom: 0, borderRadius: 0 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const LicenseExpiryBanner: React.FC<LicenseExpiryBannerProps> = ({ accessToken }) => {
|
||||
const { data } = useLicenseInfo(accessToken);
|
||||
return <LicenseExpiryBannerView licenseInfo={data ?? null} />;
|
||||
};
|
||||
|
|
@ -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(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
};
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
|
|
@ -58,7 +64,7 @@ describe("UsageIndicator", () => {
|
|||
total_users_remaining: null,
|
||||
});
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Usage")).not.toBeInTheDocument();
|
||||
|
|
@ -76,7 +82,7 @@ describe("UsageIndicator", () => {
|
|||
total_teams_remaining: 1,
|
||||
});
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
|
|
@ -94,7 +100,7 @@ describe("UsageIndicator", () => {
|
|||
total_teams_remaining: null,
|
||||
});
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
|
|
@ -112,7 +118,7 @@ describe("UsageIndicator", () => {
|
|||
total_teams_remaining: -2,
|
||||
});
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
|
|
@ -121,7 +127,7 @@ describe("UsageIndicator", () => {
|
|||
});
|
||||
|
||||
it("should render nothing when accessToken is null", () => {
|
||||
render(<UsageIndicator accessToken={null} width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken={null} width={220} />);
|
||||
|
||||
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<typeof vi.fn>).mockReturnValue(true);
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
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(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +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;
|
||||
|
|
@ -30,17 +32,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";
|
||||
|
|
@ -58,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<UsageData | null>(null);
|
||||
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | 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;
|
||||
|
|
@ -70,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");
|
||||
|
|
|
|||
61
ui/litellm-dashboard/src/utils/licenseUtils.test.ts
Normal file
61
ui/litellm-dashboard/src/utils/licenseUtils.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
51
ui/litellm-dashboard/src/utils/licenseUtils.ts
Normal file
51
ui/litellm-dashboard/src/utils/licenseUtils.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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";
|
||||
};
|
||||
|
||||
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", EXPIRY_DATE_FORMAT);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue