feat(phase-4): WS6 — Billing & Usage Metering (Stripe, free tier enforcement)
- 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>
This commit is contained in:
192
dashboard/src/components/UsagePanel.tsx
Normal file
192
dashboard/src/components/UsagePanel.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user