feat(ui): add license expiration display to usage indicator (#20763)

* feat(ui): add license expiration display to usage indicator

- Add getLicenseInfo() function to networking.tsx that calls /health/license
- Display license expiration as human-readable 'X days remaining' or 'Expires in X months'
- Show warning styling (yellow) if license expires in < 30 days
- Show error styling (red) if license is expired
- Fetch license info in parallel with usage data for efficiency
- Include license type display in expanded card view
- Compact UI suitable for sidebar widget

* fix: timezone mismatch in license expiration calculation

Addresses Greptile review feedback - forces UTC midnight for expiration
date and normalizes current date to local midnight to prevent off-by-one
errors in days remaining calculation.

---------

Co-authored-by: Shin <shin@openclaw.dev>
This commit is contained in:
shin-bot-litellm
2026-02-10 22:08:44 -08:00
committed by Sameer Kankute
parent f32f8ebf70
commit e2fa31edce
2 changed files with 186 additions and 20 deletions
@@ -1,8 +1,8 @@
import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator";
import { Badge } from "@tremor/react";
import { AlertTriangle, ChevronDown, ChevronUp, Loader2, Minus, TrendingUp, UserCheck, Users } from "lucide-react";
import { AlertTriangle, Calendar, ChevronDown, ChevronUp, Loader2, Minus, TrendingUp, UserCheck, Users } from "lucide-react";
import { useEffect, useState } from "react";
import { getRemainingUsers } from "./networking";
import { getRemainingUsers, getLicenseInfo, LicenseInfo } from "./networking";
// Simple utility function to combine class names
const cn = (...classes: (string | boolean | undefined)[]) => {
@@ -23,11 +23,35 @@ 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";
if (daysRemaining < 0) return "Expired";
if (daysRemaining === 0) return "Expires today";
if (daysRemaining === 1) return "1 day remaining";
if (daysRemaining < 30) return `${daysRemaining} days remaining`;
if (daysRemaining < 60) return "1 month remaining";
const months = Math.floor(daysRemaining / 30);
return `${months} months remaining`;
};
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 [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -39,8 +63,12 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
setError(null);
try {
const result = await getRemainingUsers(accessToken);
setData(result);
const [usageResult, licenseResult] = await Promise.all([
getRemainingUsers(accessToken),
getLicenseInfo(accessToken).catch(() => null), // Don't fail if license endpoint unavailable
]);
setData(usageResult);
setLicenseInfo(licenseResult);
} catch (err) {
console.error("Failed to fetch usage data:", err);
setError("Failed to load usage data");
@@ -52,6 +80,13 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
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) {
@@ -106,35 +141,38 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
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 (isOverLimit) return "red";
if (isNearLimit) return "yellow";
if (hasError) return "red";
if (hasWarning) return "yellow";
return "green";
};
const getStatusIcon = () => {
if (isOverLimit) return <AlertTriangle className="h-3 w-3" />;
if (isNearLimit) return <TrendingUp className="h-3 w-3" />;
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 = () => {
const hasIssues = isOverLimit || isNearLimit;
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 w-full",
hasIssues && isOverLimit && "text-red-400 hover:text-red-600",
hasIssues && isNearLimit && "text-yellow-500 hover:text-yellow-700",
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 flex-shrink-0" />
{hasIssues && <span className="flex-shrink-0">{getStatusIcon()}</span>}
{hasAnyIssue && <span className="flex-shrink-0">{getStatusIcon()}</span>}
<div className="flex items-center gap-1 truncate">
{data && data.total_users !== null && (
<span className="flex-shrink-0">
@@ -146,8 +184,17 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
T:{data.total_teams_used}/{data.total_teams}
</span>
)}
{licenseInfo?.expiration_date && daysUntilExpiration !== null && (
<span className={cn(
"flex-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 && <span className="truncate">Usage</span>)}
(data.total_users === null && data.total_teams === null && !licenseInfo && <span className="truncate">Usage</span>)}
</div>
</button>
</div>
@@ -198,13 +245,13 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
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",
isOverLimit && "text-red-600",
isNearLimit && "text-yellow-600",
hasError && "text-red-600",
hasWarning && "text-yellow-600",
)}
>
<Users className="h-4 w-4 flex-shrink-0" />
<span className="text-sm font-medium truncate">Usage Status</span>
{(isOverLimit || isNearLimit) && (
{hasAnyIssue && (
<Badge color={getStatusColor()} className="text-xs px-1.5 py-0.5 flex-shrink-0">
{getStatusIcon()}
</Badge>
@@ -229,6 +276,28 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
{/* 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">{formatExpirationDisplay(daysUntilExpiration)}</span>
</div>
</div>
)}
{/* Users section */}
{data.total_users !== null && (
<div>
@@ -323,7 +392,6 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
// Optimized CardStyleView for 220px width
const CardStyleView = () => {
if (isMinimized) {
const hasIssues = isOverLimit || isNearLimit;
return (
<button
onClick={() => setIsMinimized(false)}
@@ -334,7 +402,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 flex-shrink-0" />
{hasIssues && <span className="flex-shrink-0">{getStatusIcon()}</span>}
{hasAnyIssue && <span className="flex-shrink-0">{getStatusIcon()}</span>}
<div className="flex items-center gap-2 text-sm font-medium truncate">
{data && data.total_users !== null && (
<span
@@ -360,8 +428,20 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
T: {data.total_teams_used}/{data.total_teams}
</span>
)}
{licenseInfo?.expiration_date && daysUntilExpiration !== null && (
<span
className={cn(
"flex-shrink-0 px-1.5 py-0.5 rounded 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 && <span className="truncate">Usage</span>)}
(data.total_users === null && data.total_teams === null && !licenseInfo && <span className="truncate">Usage</span>)}
</div>
</div>
</button>
@@ -416,6 +496,50 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
{/* 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 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",
)}
>
{formatExpirationDisplay(daysUntilExpiration)}
</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
@@ -7937,6 +7937,48 @@ export const getRemainingUsers = async (
}
};
export interface LicenseInfo {
has_license: boolean;
license_type: string | null;
expiration_date: string | null;
allowed_features: string[];
limits: {
max_users: number | null;
max_teams: number | null;
};
}
export const getLicenseInfo = async (
accessToken: string,
): Promise<LicenseInfo | null> => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/health/license` : `/health/license`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
// if 404 - return null (endpoint not available)
if (response.status === 404) {
return null;
}
const errorData = await response.text();
handleError(errorData);
throw new Error("Network response was not ok");
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to fetch license info:", error);
throw error;
}
};
export const updatePassThroughEndpoint = async (
accessToken: string,
endpointPath: string,