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) <noreply@anthropic.com>
This commit is contained in:
@@ -6,20 +6,30 @@ interface Props {
|
|||||||
label: string;
|
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) {
|
export function UsageIndicator({ used, limit, label }: Props) {
|
||||||
const unlimited = limit < 0;
|
const unlimited = limit < 0;
|
||||||
const pct = unlimited ? 0 : Math.min((used / limit) * 100, 100);
|
const pct = unlimited ? 0 : limit === 0 ? 100 : Math.min((used / limit) * 100, 100);
|
||||||
const color = pct >= 100 ? 'var(--error)' : pct >= 80 ? 'var(--warning)' : 'var(--success)';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div className={styles.usageRow}>
|
||||||
<div className={styles.kvRow}>
|
<div className={styles.usageHeader}>
|
||||||
<span className={styles.kvLabel}>{label}</span>
|
<span className={styles.usageLabel}>{label}</span>
|
||||||
<span className={styles.kvValue}>{used} / {unlimited ? '\u221e' : limit}</span>
|
<span className={styles.usageValue} style={pct >= 100 ? { color: 'var(--error)', fontWeight: 600 } : undefined}>
|
||||||
|
{used.toLocaleString()}{' / '}{unlimited ? '\u221e' : limit.toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!unlimited && (
|
{!unlimited && (
|
||||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--bg-inset)', overflow: 'hidden' }}>
|
<div className={styles.usageBarTrack}>
|
||||||
<div style={{ width: `${pct}%`, height: '100%', borderRadius: 2, background: color, transition: 'width 0.3s' }} />
|
<div
|
||||||
|
className={styles.usageBarFill}
|
||||||
|
style={{ width: `${Math.max(pct, 2)}%`, background: barColor(pct) }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
Alert,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import { Copy, Eye, EyeOff } from 'lucide-react';
|
import { Copy, Eye, EyeOff } from 'lucide-react';
|
||||||
import { useTenantLicense } from '../../api/tenant-hooks';
|
import { useTenantLicense } from '../../api/tenant-hooks';
|
||||||
|
import { UsageIndicator } from '../../components/UsageIndicator';
|
||||||
import { tierColor } from '../../utils/tier';
|
import { tierColor } from '../../utils/tier';
|
||||||
import styles from '../../styles/platform.module.css';
|
import styles from '../../styles/platform.module.css';
|
||||||
|
|
||||||
@@ -28,6 +29,13 @@ const LIMIT_LABELS: Record<string, string> = {
|
|||||||
max_jar_retention_count: 'JAR Retention Count',
|
max_jar_retention_count: 'JAR Retention Count',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const METERED_LIMITS: Record<string, string> = {
|
||||||
|
max_agents: 'agents',
|
||||||
|
max_environments: 'environments',
|
||||||
|
max_apps: 'apps',
|
||||||
|
max_users: 'users',
|
||||||
|
};
|
||||||
|
|
||||||
function daysColor(days: number): 'success' | 'warning' | 'error' | 'auto' {
|
function daysColor(days: number): 'success' | 'warning' | 'error' | 'auto' {
|
||||||
if (days <= 0) return 'error';
|
if (days <= 0) return 'error';
|
||||||
if (days <= 30) return 'warning';
|
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() {
|
async function handleCopy() {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(data!.token);
|
await navigator.clipboard.writeText(data!.token);
|
||||||
@@ -68,14 +92,12 @@ export function TenantLicensePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
{/* Header */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>License</h1>
|
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>License</h1>
|
||||||
<Badge label={data.tier} color={tierColor(data.tier)} />
|
<Badge label={data.tier} color={tierColor(data.tier)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}>
|
||||||
{/* Validity card */}
|
|
||||||
<Card title="Validity">
|
<Card title="Validity">
|
||||||
<div className={styles.dividerList}>
|
<div className={styles.dividerList}>
|
||||||
<div className={styles.kvRow}>
|
<div className={styles.kvRow}>
|
||||||
@@ -108,25 +130,37 @@ export function TenantLicensePage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Limits card */}
|
{meteredEntries.length > 0 && (
|
||||||
<Card title="Limits">
|
<Card title="Resource Usage">
|
||||||
<div className={styles.dividerList}>
|
<div className={styles.dividerList}>
|
||||||
{Object.entries(data.limits).map(([key, value]) => (
|
{meteredEntries.map((entry) => (
|
||||||
<div key={key} className={styles.kvRow}>
|
<UsageIndicator
|
||||||
<span className={styles.kvLabel}>{LIMIT_LABELS[key] ?? key}</span>
|
key={entry.label}
|
||||||
<span className={styles.kvValue} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
label={entry.label}
|
||||||
{value.toLocaleString()}
|
used={entry.used}
|
||||||
</span>
|
limit={entry.limit}
|
||||||
</div>
|
/>
|
||||||
))}
|
))}
|
||||||
{Object.keys(data.limits).length === 0 && (
|
</div>
|
||||||
<p className={styles.description}>No limits configured.</p>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</Card>
|
{configEntries.length > 0 && (
|
||||||
|
<Card title="Plan Limits">
|
||||||
|
<div className={styles.dividerList}>
|
||||||
|
{configEntries.map((entry) => (
|
||||||
|
<div key={entry.label} className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>{entry.label}</span>
|
||||||
|
<span className={styles.kvValue} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{entry.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* License token card — full width */}
|
|
||||||
<Card title="License Token">
|
<Card title="License Token">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
<p className={styles.description}>
|
<p className={styles.description}>
|
||||||
@@ -134,7 +168,7 @@ export function TenantLicensePage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className={styles.tokenBlock}>
|
<div className={styles.tokenBlock}>
|
||||||
<code className={styles.tokenCode}>
|
<code className={styles.tokenCode}>
|
||||||
{showToken ? data.token : '••••••••••••••••••••••••••••••••'}
|
{showToken ? data.token : '\u2022'.repeat(32)}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
|||||||
26
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
26
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
@@ -22,6 +22,7 @@ import {
|
|||||||
} from '../../api/vendor-hooks';
|
} from '../../api/vendor-hooks';
|
||||||
import { errorMessage } from '../../api/client';
|
import { errorMessage } from '../../api/client';
|
||||||
import { ServerStatusBadge } from '../../components/ServerStatusBadge';
|
import { ServerStatusBadge } from '../../components/ServerStatusBadge';
|
||||||
|
import { UsageIndicator } from '../../components/UsageIndicator';
|
||||||
import { tierColor } from '../../utils/tier';
|
import { tierColor } from '../../utils/tier';
|
||||||
import styles from '../../styles/platform.module.css';
|
import styles from '../../styles/platform.module.css';
|
||||||
import type { LicenseBundleResponse, MintLicenseRequest } from '../../types/api';
|
import type { LicenseBundleResponse, MintLicenseRequest } from '../../types/api';
|
||||||
@@ -44,6 +45,13 @@ const LIMIT_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
const LIMIT_KEYS = Object.keys(LIMIT_LABELS);
|
const LIMIT_KEYS = Object.keys(LIMIT_LABELS);
|
||||||
|
|
||||||
|
const METERED_LIMITS: Record<string, string> = {
|
||||||
|
max_agents: 'agents',
|
||||||
|
max_environments: 'environments',
|
||||||
|
max_apps: 'apps',
|
||||||
|
max_users: 'users',
|
||||||
|
};
|
||||||
|
|
||||||
function licenseDaysRemaining(expiresAt: string | null | undefined): number {
|
function licenseDaysRemaining(expiresAt: string | null | undefined): number {
|
||||||
if (!expiresAt) return 0;
|
if (!expiresAt) return 0;
|
||||||
return Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000);
|
return Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000);
|
||||||
@@ -340,6 +348,24 @@ export function TenantDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Resource Usage */}
|
||||||
|
{data.usage && Object.keys(data.usage).length > 0 && license && (
|
||||||
|
<Card title="Resource Usage">
|
||||||
|
<div className={styles.dividerList}>
|
||||||
|
{Object.entries(METERED_LIMITS)
|
||||||
|
.filter(([limitKey]) => license.limits[limitKey] != null)
|
||||||
|
.map(([limitKey, usageKey]) => (
|
||||||
|
<UsageIndicator
|
||||||
|
key={limitKey}
|
||||||
|
label={LIMIT_LABELS[limitKey] ?? limitKey}
|
||||||
|
used={data.usage[usageKey] ?? 0}
|
||||||
|
limit={license.limits[limitKey]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tenant Info */}
|
{/* Tenant Info */}
|
||||||
<Card title="Tenant Info">
|
<Card title="Tenant Info">
|
||||||
<div className={styles.dividerList}>
|
<div className={styles.dividerList}>
|
||||||
|
|||||||
@@ -61,3 +61,42 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
white-space: pre-wrap;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export interface VendorTenantDetail {
|
|||||||
serverHealthy: boolean;
|
serverHealthy: boolean;
|
||||||
serverStatus: string;
|
serverStatus: string;
|
||||||
license: LicenseResponse | null;
|
license: LicenseResponse | null;
|
||||||
|
usage: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTenantRequest {
|
export interface CreateTenantRequest {
|
||||||
@@ -125,6 +126,7 @@ export interface TenantLicenseData {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
token: string;
|
token: string;
|
||||||
daysRemaining: number;
|
daysRemaining: number;
|
||||||
|
usage: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TenantSettings {
|
export interface TenantSettings {
|
||||||
|
|||||||
Reference in New Issue
Block a user