From d78304003023af765eb725c3675340a74cd53194 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:12:33 +0200 Subject: [PATCH] feat(ui): add license usage visualization with progress bars Split license limits into metered "Resource Usage" (with color-coded progress bars) and static "Plan Limits" cards. Updated UsageIndicator with 8px bars, green/amber/red thresholds, and tabular-nums formatting. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/UsageIndicator.tsx | 26 +++++--- ui/src/pages/tenant/TenantLicensePage.tsx | 76 ++++++++++++++++------- ui/src/pages/vendor/TenantDetailPage.tsx | 26 ++++++++ ui/src/styles/platform.module.css | 39 ++++++++++++ ui/src/types/api.ts | 2 + 5 files changed, 140 insertions(+), 29 deletions(-) diff --git a/ui/src/components/UsageIndicator.tsx b/ui/src/components/UsageIndicator.tsx index c62f32d..e25c653 100644 --- a/ui/src/components/UsageIndicator.tsx +++ b/ui/src/components/UsageIndicator.tsx @@ -6,20 +6,30 @@ interface Props { label: string; } +function barColor(pct: number): string { + if (pct >= 100) return 'var(--error)'; + if (pct >= 75) return 'var(--warning)'; + return 'var(--success)'; +} + export function UsageIndicator({ used, limit, label }: Props) { const unlimited = limit < 0; - const pct = unlimited ? 0 : Math.min((used / limit) * 100, 100); - const color = pct >= 100 ? 'var(--error)' : pct >= 80 ? 'var(--warning)' : 'var(--success)'; + const pct = unlimited ? 0 : limit === 0 ? 100 : Math.min((used / limit) * 100, 100); return ( -
-
- {label} - {used} / {unlimited ? '\u221e' : limit} +
+
+ {label} + = 100 ? { color: 'var(--error)', fontWeight: 600 } : undefined}> + {used.toLocaleString()}{' / '}{unlimited ? '\u221e' : limit.toLocaleString()} +
{!unlimited && ( -
-
+
+
)}
diff --git a/ui/src/pages/tenant/TenantLicensePage.tsx b/ui/src/pages/tenant/TenantLicensePage.tsx index 03dac71..92bb3de 100644 --- a/ui/src/pages/tenant/TenantLicensePage.tsx +++ b/ui/src/pages/tenant/TenantLicensePage.tsx @@ -1,14 +1,15 @@ import { useState } from 'react'; import { - Alert, Badge, Button, Card, Spinner, + Alert, useToast, } from '@cameleer/design-system'; import { Copy, Eye, EyeOff } from 'lucide-react'; import { useTenantLicense } from '../../api/tenant-hooks'; +import { UsageIndicator } from '../../components/UsageIndicator'; import { tierColor } from '../../utils/tier'; import styles from '../../styles/platform.module.css'; @@ -28,6 +29,13 @@ const LIMIT_LABELS: Record = { max_jar_retention_count: 'JAR Retention Count', }; +const METERED_LIMITS: Record = { + max_agents: 'agents', + max_environments: 'environments', + max_apps: 'apps', + max_users: 'users', +}; + function daysColor(days: number): 'success' | 'warning' | 'error' | 'auto' { if (days <= 0) return 'error'; if (days <= 30) return 'warning'; @@ -57,6 +65,22 @@ export function TenantLicensePage() { ); } + const usage = data.usage ?? {}; + const meteredEntries = Object.entries(METERED_LIMITS) + .filter(([limitKey]) => data.limits[limitKey] != null) + .map(([limitKey, usageKey]) => ({ + label: LIMIT_LABELS[limitKey] ?? limitKey, + used: usage[usageKey] ?? 0, + limit: data.limits[limitKey], + })); + + const configEntries = Object.entries(data.limits) + .filter(([key]) => !(key in METERED_LIMITS)) + .map(([key, value]) => ({ + label: LIMIT_LABELS[key] ?? key, + value, + })); + async function handleCopy() { try { await navigator.clipboard.writeText(data!.token); @@ -68,14 +92,12 @@ export function TenantLicensePage() { return (
- {/* Header */}

License

- {/* Validity card */}
@@ -108,25 +130,37 @@ export function TenantLicensePage() {
- {/* Limits card */} - -
- {Object.entries(data.limits).map(([key, value]) => ( -
- {LIMIT_LABELS[key] ?? key} - - {value.toLocaleString()} - -
- ))} - {Object.keys(data.limits).length === 0 && ( -

No limits configured.

- )} -
-
+ {meteredEntries.length > 0 && ( + +
+ {meteredEntries.map((entry) => ( + + ))} +
+
+ )} + + {configEntries.length > 0 && ( + +
+ {configEntries.map((entry) => ( +
+ {entry.label} + + {entry.value.toLocaleString()} + +
+ ))} +
+
+ )}
- {/* License token card — full width */}

@@ -134,7 +168,7 @@ export function TenantLicensePage() {

- {showToken ? data.token : '••••••••••••••••••••••••••••••••'} + {showToken ? data.token : '\u2022'.repeat(32)}
diff --git a/ui/src/pages/vendor/TenantDetailPage.tsx b/ui/src/pages/vendor/TenantDetailPage.tsx index 8e3bfa1..ca2057e 100644 --- a/ui/src/pages/vendor/TenantDetailPage.tsx +++ b/ui/src/pages/vendor/TenantDetailPage.tsx @@ -22,6 +22,7 @@ import { } from '../../api/vendor-hooks'; import { errorMessage } from '../../api/client'; import { ServerStatusBadge } from '../../components/ServerStatusBadge'; +import { UsageIndicator } from '../../components/UsageIndicator'; import { tierColor } from '../../utils/tier'; import styles from '../../styles/platform.module.css'; import type { LicenseBundleResponse, MintLicenseRequest } from '../../types/api'; @@ -44,6 +45,13 @@ const LIMIT_LABELS: Record = { const LIMIT_KEYS = Object.keys(LIMIT_LABELS); +const METERED_LIMITS: Record = { + max_agents: 'agents', + max_environments: 'environments', + max_apps: 'apps', + max_users: 'users', +}; + function licenseDaysRemaining(expiresAt: string | null | undefined): number { if (!expiresAt) return 0; return Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000); @@ -340,6 +348,24 @@ export function TenantDetailPage() { )} + {/* Resource Usage */} + {data.usage && Object.keys(data.usage).length > 0 && license && ( + +
+ {Object.entries(METERED_LIMITS) + .filter(([limitKey]) => license.limits[limitKey] != null) + .map(([limitKey, usageKey]) => ( + + ))} +
+
+ )} + {/* Tenant Info */}
diff --git a/ui/src/styles/platform.module.css b/ui/src/styles/platform.module.css index 76f8746..52be069 100644 --- a/ui/src/styles/platform.module.css +++ b/ui/src/styles/platform.module.css @@ -61,3 +61,42 @@ word-break: break-all; white-space: pre-wrap; } + +/* Usage progress bar */ +.usageRow { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 0; +} + +.usageHeader { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.usageLabel { + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-primary); +} + +.usageValue { + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + color: var(--text-secondary); +} + +.usageBarTrack { + height: 8px; + border-radius: 4px; + background: var(--bg-inset); + overflow: hidden; +} + +.usageBarFill { + height: 100%; + border-radius: 4px; + transition: width 0.4s ease, background-color 0.3s ease; +} diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts index b375e09..5f7d8ef 100644 --- a/ui/src/types/api.ts +++ b/ui/src/types/api.ts @@ -89,6 +89,7 @@ export interface VendorTenantDetail { serverHealthy: boolean; serverStatus: string; license: LicenseResponse | null; + usage: Record; } export interface CreateTenantRequest { @@ -125,6 +126,7 @@ export interface TenantLicenseData { expiresAt: string; token: string; daysRemaining: number; + usage: Record; } export interface TenantSettings {