- DB migration 023: tenant_subscriptions and usage_events tables - UsageMeteringMiddleware: in-memory counters, 60s flush to DB via UPSERT - FreeTierEnforcementMiddleware: 10 agents / 1,000 calls/day limits, Redis cache - UsageService: getDailyUsage and getActiveAgentCount - BillingService: Stripe checkout sessions, webhook verification, subscription status - POST /billing/checkout, POST /billing/webhook, GET /billing/usage endpoints - BILLING_ENABLED=false disables enforcement without breaking metering - Dashboard: Usage tab with Free Tier/Pro badges and metric cards - 19 unit tests passing across billing services and middleware Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
193 lines
5.9 KiB
TypeScript
193 lines
5.9 KiB
TypeScript
import * as React from 'react';
|
|
import { useAuth } from '@/lib/auth';
|
|
import { TokenManager } from '@sentryagent/idp-sdk';
|
|
|
|
/** Shape of the GET /api/v1/billing/usage response. */
|
|
interface UsageResponse {
|
|
tenantId: string;
|
|
date: string;
|
|
apiCalls: number;
|
|
agentCount: number;
|
|
subscriptionStatus: string;
|
|
currentPeriodEnd: string | null;
|
|
stripeSubscriptionId: string | null;
|
|
}
|
|
|
|
type LoadState = 'idle' | 'loading' | 'success' | 'error';
|
|
|
|
interface UsageState {
|
|
loadState: LoadState;
|
|
data: UsageResponse | null;
|
|
errorMessage: string | null;
|
|
}
|
|
|
|
const initialState: UsageState = {
|
|
loadState: 'idle',
|
|
data: null,
|
|
errorMessage: null,
|
|
};
|
|
|
|
/**
|
|
* Fetches the current usage summary from the API using the stored credentials.
|
|
*
|
|
* @param baseUrl - The API base URL.
|
|
* @param clientId - The agent client ID.
|
|
* @param clientSecret - The agent client secret.
|
|
* @returns The usage response from the server.
|
|
*/
|
|
async function fetchUsage(
|
|
baseUrl: string,
|
|
clientId: string,
|
|
clientSecret: string,
|
|
): Promise<UsageResponse> {
|
|
const tokenManager = new TokenManager(
|
|
baseUrl,
|
|
clientId,
|
|
clientSecret,
|
|
'agents:read',
|
|
);
|
|
const token = await tokenManager.getToken();
|
|
|
|
const response = await fetch(`${baseUrl}/api/v1/billing/usage`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch usage data (HTTP ${response.status})`);
|
|
}
|
|
|
|
return response.json() as Promise<UsageResponse>;
|
|
}
|
|
|
|
/** Badge shown for the tenant's subscription tier. */
|
|
function SubscriptionBadge({ status }: { status: string }): React.JSX.Element {
|
|
const isPro = status !== 'free';
|
|
|
|
return (
|
|
<span
|
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
|
isPro
|
|
? 'bg-brand-100 text-brand-700'
|
|
: 'bg-slate-100 text-slate-600'
|
|
}`}
|
|
>
|
|
{isPro ? 'Pro' : 'Free Tier'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** A single metric card with label and value. */
|
|
function MetricCard({ label, value }: { label: string; value: string | number }): React.JSX.Element {
|
|
return (
|
|
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
|
<p className="text-sm font-medium text-slate-500">{label}</p>
|
|
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Displays the current tenant's usage summary:
|
|
* - API calls today
|
|
* - Active agent count
|
|
* - Subscription status (Free Tier / Pro)
|
|
*
|
|
* Fetches GET /api/v1/billing/usage with the current Bearer token.
|
|
* Handles loading state and error state gracefully.
|
|
*/
|
|
export function UsagePanel(): React.JSX.Element {
|
|
const { credentials } = useAuth();
|
|
const [state, setState] = React.useState<UsageState>(initialState);
|
|
|
|
const loadUsage = React.useCallback(async (): Promise<void> => {
|
|
if (!credentials) return;
|
|
|
|
setState((prev) => ({ ...prev, loadState: 'loading', errorMessage: null }));
|
|
|
|
try {
|
|
const data = await fetchUsage(
|
|
credentials.baseUrl,
|
|
credentials.clientId,
|
|
credentials.clientSecret,
|
|
);
|
|
setState({ loadState: 'success', data, errorMessage: null });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Unknown error occurred.';
|
|
setState({ loadState: 'error', data: null, errorMessage: message });
|
|
}
|
|
}, [credentials]);
|
|
|
|
React.useEffect(() => {
|
|
void loadUsage();
|
|
}, [loadUsage]);
|
|
|
|
const isLoading = state.loadState === 'loading' || state.loadState === 'idle';
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold text-slate-900">Usage & Billing</h1>
|
|
<button
|
|
onClick={() => { void loadUsage(); }}
|
|
disabled={isLoading}
|
|
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 disabled:opacity-40"
|
|
>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error state */}
|
|
{state.loadState === 'error' && (
|
|
<div className="mb-6 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
|
{state.errorMessage ?? 'Failed to load usage data.'}
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading skeleton */}
|
|
{isLoading && (
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 animate-pulse">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="h-28 rounded-xl border border-slate-200 bg-slate-100" />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Data */}
|
|
{state.loadState === 'success' && state.data !== null && (
|
|
<>
|
|
<div className="mb-4 flex items-center gap-3">
|
|
<p className="text-sm text-slate-500">
|
|
Showing usage for <strong>{state.data.date}</strong>
|
|
</p>
|
|
<SubscriptionBadge status={state.data.subscriptionStatus} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
<MetricCard label="API Calls Today" value={state.data.apiCalls.toLocaleString()} />
|
|
<MetricCard label="Active Agents" value={state.data.agentCount.toLocaleString()} />
|
|
<MetricCard label="Plan" value={state.data.subscriptionStatus === 'free' ? 'Free Tier' : 'Pro'} />
|
|
</div>
|
|
|
|
{state.data.subscriptionStatus === 'free' && (
|
|
<div className="mt-6 rounded-xl border border-brand-200 bg-brand-50 p-5">
|
|
<p className="text-sm font-medium text-brand-800">
|
|
You are on the Free Tier — limited to 10 agents and 1,000 API calls/day.
|
|
</p>
|
|
<p className="mt-1 text-sm text-brand-700">
|
|
Upgrade to Pro for unlimited agents and API calls.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{state.data.currentPeriodEnd !== null && (
|
|
<p className="mt-4 text-xs text-slate-400">
|
|
Current period ends:{' '}
|
|
{new Date(state.data.currentPeriodEnd).toLocaleDateString()}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|