1046 lines
46 KiB
TypeScript
1046 lines
46 KiB
TypeScript
import { BarChart3, Loader2, Search, ShieldCheck, ShieldAlert } from 'lucide-react';
|
|
import { useEffect, useState } from 'react';
|
|
import type {
|
|
AdminAnalyticsSummary,
|
|
AdminAuditAction,
|
|
AdminAuditLogItem,
|
|
AdminSecurityPostureResponse,
|
|
AdminSupportDiagnosticsResponse,
|
|
ApplicationAdminSummary,
|
|
BillingAdminWorkspaceDetail,
|
|
BillingAdminWorkspaceSummary,
|
|
} from '@/shared/types';
|
|
import { formatBillingStatusLabel, formatDateLabel } from '@/app/src/lib/billing-ui';
|
|
import {
|
|
getAdminAnalyticsSummary,
|
|
getAdminBillingWorkspaceDetail,
|
|
getAdminSecurityPosture,
|
|
getAdminSupportDiagnostics,
|
|
listAdminAuditLogs,
|
|
listAdminBillingWorkspaces,
|
|
listApplicationAdmins,
|
|
requestAdminBillingResync,
|
|
updateApplicationAdminStatus,
|
|
upsertApplicationAdmin,
|
|
} from '../lib/admin';
|
|
import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader, Select } from '@/app/src/components/ui';
|
|
|
|
const MIN_SUMMARY_DAYS = 7;
|
|
const MAX_SUMMARY_DAYS = 90;
|
|
const DEFAULT_SUMMARY_DAYS = 30;
|
|
const DEFAULT_AUDIT_PAGE_SIZE = 25;
|
|
const DEFAULT_DIAGNOSTICS_WINDOW_DAYS = 7;
|
|
const DEFAULT_STALE_THRESHOLD_HOURS = 24;
|
|
|
|
const AUDIT_ACTION_OPTIONS: Array<{ value: AdminAuditAction; label: string }> = [
|
|
{ value: 'admin_access_list', label: 'Admin access list' },
|
|
{ value: 'admin_access_upsert', label: 'Admin access upsert' },
|
|
{ value: 'admin_access_status_changed', label: 'Admin access status changed' },
|
|
{ value: 'analytics_summary', label: 'Analytics summary' },
|
|
{ value: 'billing_workspaces_list', label: 'Billing workspaces list' },
|
|
{ value: 'billing_workspace_detail', label: 'Billing workspace detail' },
|
|
{ value: 'bootstrap_admin_claimed', label: 'Bootstrap admin claimed' },
|
|
{ value: 'admin_ops_audit_list', label: 'Admin ops audit list' },
|
|
{ value: 'admin_ops_security_posture', label: 'Admin ops security posture' },
|
|
{ value: 'admin_ops_diagnostics', label: 'Admin ops diagnostics' },
|
|
{ value: 'admin_mutation_billing_resync_requested', label: 'Admin mutation billing resync requested' },
|
|
];
|
|
|
|
function formatAuditActionLabel(action: AdminAuditAction) {
|
|
const matched = AUDIT_ACTION_OPTIONS.find((option) => option.value === action);
|
|
return matched?.label ?? action;
|
|
}
|
|
|
|
const clampSummaryDays = (value: number) => Math.min(MAX_SUMMARY_DAYS, Math.max(MIN_SUMMARY_DAYS, value));
|
|
|
|
function toIsoDateTime(value: string) {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return undefined;
|
|
}
|
|
|
|
return parsed.toISOString();
|
|
}
|
|
|
|
export function AdminPage() {
|
|
const [summaryDays, setSummaryDays] = useState(DEFAULT_SUMMARY_DAYS);
|
|
const [analyticsSummary, setAnalyticsSummary] = useState<AdminAnalyticsSummary | null>(null);
|
|
const [summaryLoading, setSummaryLoading] = useState(true);
|
|
const [summaryError, setSummaryError] = useState<string | null>(null);
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [workspaces, setWorkspaces] = useState<BillingAdminWorkspaceSummary[]>([]);
|
|
const [selectedWorkspace, setSelectedWorkspace] = useState<BillingAdminWorkspaceDetail | null>(null);
|
|
const [workspacesLoading, setWorkspacesLoading] = useState(true);
|
|
const [workspacesError, setWorkspacesError] = useState<string | null>(null);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
|
|
const [admins, setAdmins] = useState<ApplicationAdminSummary[]>([]);
|
|
const [adminsLoading, setAdminsLoading] = useState(true);
|
|
const [adminsError, setAdminsError] = useState<string | null>(null);
|
|
const [adminMutationFeedback, setAdminMutationFeedback] = useState<string | null>(null);
|
|
const [adminMutationError, setAdminMutationError] = useState<string | null>(null);
|
|
const [adminEmailInput, setAdminEmailInput] = useState('');
|
|
const [adminEmailSubmitting, setAdminEmailSubmitting] = useState(false);
|
|
const [statusMutationAdminId, setStatusMutationAdminId] = useState<string | null>(null);
|
|
|
|
const [securityPosture, setSecurityPosture] = useState<AdminSecurityPostureResponse | null>(null);
|
|
const [securityLoading, setSecurityLoading] = useState(true);
|
|
const [securityError, setSecurityError] = useState<string | null>(null);
|
|
|
|
const [diagnostics, setDiagnostics] = useState<AdminSupportDiagnosticsResponse | null>(null);
|
|
const [diagnosticsLoading, setDiagnosticsLoading] = useState(true);
|
|
const [diagnosticsError, setDiagnosticsError] = useState<string | null>(null);
|
|
|
|
const [auditItems, setAuditItems] = useState<AdminAuditLogItem[]>([]);
|
|
const [auditLoading, setAuditLoading] = useState(true);
|
|
const [auditError, setAuditError] = useState<string | null>(null);
|
|
const [auditPage, setAuditPage] = useState(1);
|
|
const [auditTotal, setAuditTotal] = useState(0);
|
|
const [auditActorEmail, setAuditActorEmail] = useState('');
|
|
const [auditAction, setAuditAction] = useState<AdminAuditAction | ''>('');
|
|
const [auditWorkspaceId, setAuditWorkspaceId] = useState('');
|
|
const [auditFrom, setAuditFrom] = useState('');
|
|
const [auditTo, setAuditTo] = useState('');
|
|
|
|
const [mutationWorkspaceId, setMutationWorkspaceId] = useState('');
|
|
const [mutationReason, setMutationReason] = useState('');
|
|
const [mutationTicketRef, setMutationTicketRef] = useState('');
|
|
const [mutationConfirmation, setMutationConfirmation] = useState('');
|
|
const [mutationSubmitting, setMutationSubmitting] = useState(false);
|
|
const [mutationFeedback, setMutationFeedback] = useState<string | null>(null);
|
|
const [mutationError, setMutationError] = useState<string | null>(null);
|
|
|
|
const loadAuditLogs = async (
|
|
page: number,
|
|
overrides?: Partial<{ actorEmail: string; action: AdminAuditAction | ''; workspaceId: string; from: string; to: string }>,
|
|
) => {
|
|
const actorEmail = overrides?.actorEmail ?? auditActorEmail;
|
|
const action = overrides?.action ?? auditAction;
|
|
const workspaceId = overrides?.workspaceId ?? auditWorkspaceId;
|
|
const from = overrides?.from ?? auditFrom;
|
|
const to = overrides?.to ?? auditTo;
|
|
|
|
setAuditLoading(true);
|
|
setAuditError(null);
|
|
|
|
try {
|
|
const response = await listAdminAuditLogs({
|
|
actorEmail: actorEmail.trim() || undefined,
|
|
action: action || undefined,
|
|
workspaceId: workspaceId.trim() || undefined,
|
|
from: toIsoDateTime(from),
|
|
to: toIsoDateTime(to),
|
|
page,
|
|
pageSize: DEFAULT_AUDIT_PAGE_SIZE,
|
|
});
|
|
|
|
setAuditItems(response.items);
|
|
setAuditTotal(response.total);
|
|
setAuditPage(response.page);
|
|
} catch (error) {
|
|
setAuditError(error instanceof Error ? error.message : 'Failed to load admin audit logs.');
|
|
} finally {
|
|
setAuditLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadSecurityPosture = async () => {
|
|
setSecurityLoading(true);
|
|
setSecurityError(null);
|
|
|
|
try {
|
|
const response = await getAdminSecurityPosture();
|
|
setSecurityPosture(response);
|
|
} catch (error) {
|
|
setSecurityError(error instanceof Error ? error.message : 'Failed to load security posture.');
|
|
} finally {
|
|
setSecurityLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadDiagnostics = async () => {
|
|
setDiagnosticsLoading(true);
|
|
setDiagnosticsError(null);
|
|
|
|
try {
|
|
const response = await getAdminSupportDiagnostics({
|
|
windowDays: DEFAULT_DIAGNOSTICS_WINDOW_DAYS,
|
|
staleSyncThresholdHours: DEFAULT_STALE_THRESHOLD_HOURS,
|
|
sampleLimit: 10,
|
|
});
|
|
setDiagnostics(response);
|
|
} catch (error) {
|
|
setDiagnosticsError(error instanceof Error ? error.message : 'Failed to load support diagnostics.');
|
|
} finally {
|
|
setDiagnosticsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const loadAdminData = async () => {
|
|
setSummaryLoading(true);
|
|
setWorkspacesLoading(true);
|
|
setAdminsLoading(true);
|
|
setSummaryError(null);
|
|
setWorkspacesError(null);
|
|
setAdminsError(null);
|
|
|
|
try {
|
|
const [summary, workspaceResponse, adminResponse, securityResponse, diagnosticsResponse, auditResponse] = await Promise.all([
|
|
getAdminAnalyticsSummary(DEFAULT_SUMMARY_DAYS),
|
|
listAdminBillingWorkspaces(),
|
|
listApplicationAdmins(),
|
|
getAdminSecurityPosture(),
|
|
getAdminSupportDiagnostics({
|
|
windowDays: DEFAULT_DIAGNOSTICS_WINDOW_DAYS,
|
|
staleSyncThresholdHours: DEFAULT_STALE_THRESHOLD_HOURS,
|
|
sampleLimit: 10,
|
|
}),
|
|
listAdminAuditLogs({ page: 1, pageSize: DEFAULT_AUDIT_PAGE_SIZE }),
|
|
]);
|
|
|
|
if (!isMounted) {
|
|
return;
|
|
}
|
|
|
|
setAnalyticsSummary(summary);
|
|
setWorkspaces(workspaceResponse.workspaces);
|
|
setAdmins(adminResponse.admins);
|
|
setSecurityPosture(securityResponse);
|
|
setDiagnostics(diagnosticsResponse);
|
|
setAuditItems(auditResponse.items);
|
|
setAuditTotal(auditResponse.total);
|
|
setAuditPage(auditResponse.page);
|
|
} catch (error) {
|
|
if (!isMounted) {
|
|
return;
|
|
}
|
|
|
|
const message = error instanceof Error ? error.message : 'Failed to load admin console data.';
|
|
setSummaryError(message);
|
|
setWorkspacesError(message);
|
|
setAdminsError(message);
|
|
setSecurityError(message);
|
|
setDiagnosticsError(message);
|
|
setAuditError(message);
|
|
} finally {
|
|
if (isMounted) {
|
|
setSummaryLoading(false);
|
|
setWorkspacesLoading(false);
|
|
setAdminsLoading(false);
|
|
setSecurityLoading(false);
|
|
setDiagnosticsLoading(false);
|
|
setAuditLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
void loadAdminData();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, []);
|
|
|
|
const handleRefreshSummary = async () => {
|
|
setSummaryLoading(true);
|
|
setSummaryError(null);
|
|
|
|
try {
|
|
const clampedDays = clampSummaryDays(summaryDays);
|
|
if (clampedDays !== summaryDays) {
|
|
setSummaryDays(clampedDays);
|
|
}
|
|
|
|
const summary = await getAdminAnalyticsSummary(clampedDays);
|
|
setAnalyticsSummary(summary);
|
|
} catch (error) {
|
|
setSummaryError(error instanceof Error ? error.message : 'Failed to refresh analytics summary.');
|
|
} finally {
|
|
setSummaryLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSearchWorkspaces = async () => {
|
|
setWorkspacesLoading(true);
|
|
setWorkspacesError(null);
|
|
|
|
try {
|
|
const response = await listAdminBillingWorkspaces(searchQuery);
|
|
setWorkspaces(response.workspaces);
|
|
setSelectedWorkspace(null);
|
|
} catch (error) {
|
|
setWorkspacesError(error instanceof Error ? error.message : 'Failed to search billing workspaces.');
|
|
} finally {
|
|
setWorkspacesLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleLoadWorkspaceDetail = async (workspaceId: string) => {
|
|
setDetailLoading(true);
|
|
setWorkspacesError(null);
|
|
|
|
try {
|
|
const response = await getAdminBillingWorkspaceDetail(workspaceId);
|
|
setSelectedWorkspace(response.workspace);
|
|
} catch (error) {
|
|
setWorkspacesError(error instanceof Error ? error.message : 'Failed to load workspace detail.');
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
};
|
|
|
|
const refreshAdmins = async () => {
|
|
setAdminsLoading(true);
|
|
setAdminsError(null);
|
|
try {
|
|
const response = await listApplicationAdmins();
|
|
setAdmins(response.admins);
|
|
} catch (error) {
|
|
setAdminsError(error instanceof Error ? error.message : 'Failed to load application admins.');
|
|
} finally {
|
|
setAdminsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUpsertAdmin = async () => {
|
|
setAdminMutationFeedback(null);
|
|
setAdminMutationError(null);
|
|
setAdminEmailSubmitting(true);
|
|
|
|
try {
|
|
const response = await upsertApplicationAdmin(adminEmailInput);
|
|
setAdminMutationFeedback(`Admin access is active for ${response.admin.email}.`);
|
|
setAdminEmailInput('');
|
|
await refreshAdmins();
|
|
await loadSecurityPosture();
|
|
} catch (error) {
|
|
setAdminMutationError(error instanceof Error ? error.message : 'Failed to add or reactivate admin.');
|
|
} finally {
|
|
setAdminEmailSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleSetAdminStatus = async (adminId: string, status: 'active' | 'disabled') => {
|
|
setAdminMutationFeedback(null);
|
|
setAdminMutationError(null);
|
|
setStatusMutationAdminId(adminId);
|
|
|
|
try {
|
|
const response = await updateApplicationAdminStatus(adminId, status);
|
|
setAdminMutationFeedback(`${response.admin.email} is now ${status}.`);
|
|
await refreshAdmins();
|
|
await loadSecurityPosture();
|
|
} catch (error) {
|
|
setAdminMutationError(error instanceof Error ? error.message : 'Failed to update admin status.');
|
|
} finally {
|
|
setStatusMutationAdminId(null);
|
|
}
|
|
};
|
|
|
|
const handleRequestBillingResync = async () => {
|
|
setMutationError(null);
|
|
setMutationFeedback(null);
|
|
setMutationSubmitting(true);
|
|
|
|
try {
|
|
const response = await requestAdminBillingResync({
|
|
workspaceId: mutationWorkspaceId.trim(),
|
|
reason: mutationReason.trim(),
|
|
confirmationText: mutationConfirmation.trim(),
|
|
ticketRef: mutationTicketRef.trim() || undefined,
|
|
});
|
|
|
|
setMutationFeedback(response.message);
|
|
|
|
if (selectedWorkspace?.summary.workspaceId === mutationWorkspaceId.trim()) {
|
|
await handleLoadWorkspaceDetail(mutationWorkspaceId.trim());
|
|
}
|
|
|
|
await loadDiagnostics();
|
|
await loadAuditLogs(1);
|
|
} catch (error) {
|
|
setMutationError(error instanceof Error ? error.message : 'Failed to request billing resync.');
|
|
} finally {
|
|
setMutationSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const totalAuditPages = Math.max(1, Math.ceil(auditTotal / DEFAULT_AUDIT_PAGE_SIZE));
|
|
const canSubmitMutation =
|
|
mutationWorkspaceId.trim().length > 0
|
|
&& mutationReason.trim().length >= 10
|
|
&& mutationConfirmation.trim() === 'RESYNC'
|
|
&& !mutationSubmitting;
|
|
|
|
return (
|
|
<PageShell>
|
|
<PageContainer>
|
|
<SectionHeader
|
|
eyebrow="Admin"
|
|
title="Admin Console"
|
|
description="Visibility into pricing analytics, admin access identities, security posture, support diagnostics, and workspace billing state."
|
|
/>
|
|
|
|
<Card className="p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
|
|
<ShieldAlert className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Safe Mutations (Pilot)</p>
|
|
<h2 className="text-lg font-semibold text-stone-950">Request billing resync for a workspace</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="mt-3 text-sm text-stone-600">
|
|
This triggers a Stripe subscription re-sync for one workspace. It does not directly change plan pricing configuration.
|
|
</p>
|
|
|
|
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
<div>
|
|
<FieldLabel>Workspace ID</FieldLabel>
|
|
<Input
|
|
value={mutationWorkspaceId}
|
|
onChange={(event) => setMutationWorkspaceId(event.target.value)}
|
|
placeholder="Workspace UUID"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<FieldLabel>Ticket reference (optional)</FieldLabel>
|
|
<Input
|
|
value={mutationTicketRef}
|
|
onChange={(event) => setMutationTicketRef(event.target.value)}
|
|
placeholder="SUP-1234"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3">
|
|
<FieldLabel>Reason (required)</FieldLabel>
|
|
<Input
|
|
value={mutationReason}
|
|
onChange={(event) => setMutationReason(event.target.value)}
|
|
placeholder="Explain why this resync is needed."
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-3">
|
|
<FieldLabel>Type RESYNC to confirm</FieldLabel>
|
|
<Input
|
|
value={mutationConfirmation}
|
|
onChange={(event) => setMutationConfirmation(event.target.value)}
|
|
placeholder="RESYNC"
|
|
/>
|
|
</div>
|
|
|
|
{mutationError ? <Alert className="mt-4" variant="error">{mutationError}</Alert> : null}
|
|
{mutationFeedback ? <Alert className="mt-4" variant="success">{mutationFeedback}</Alert> : null}
|
|
|
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
<Button type="button" onClick={() => void handleRequestBillingResync()} disabled={!canSubmitMutation}>
|
|
{mutationSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
|
Request billing resync
|
|
</Button>
|
|
{selectedWorkspace ? (
|
|
<Button
|
|
type="button"
|
|
variant="subtle"
|
|
onClick={() => setMutationWorkspaceId(selectedWorkspace.summary.workspaceId)}
|
|
disabled={mutationSubmitting}
|
|
>
|
|
Use selected workspace
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
|
|
<ShieldCheck className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Admin Access Management</p>
|
|
<h2 className="text-lg font-semibold text-stone-950">Application admin identities</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap items-end gap-3">
|
|
<div className="min-w-[260px] flex-1">
|
|
<FieldLabel>Admin email</FieldLabel>
|
|
<Input
|
|
value={adminEmailInput}
|
|
onChange={(event) => setAdminEmailInput(event.target.value)}
|
|
placeholder="name@company.com"
|
|
type="email"
|
|
/>
|
|
</div>
|
|
<Button type="button" onClick={() => void handleUpsertAdmin()} disabled={adminEmailSubmitting}>
|
|
{adminEmailSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
|
Add or reactivate
|
|
</Button>
|
|
</div>
|
|
|
|
{adminsError ? <Alert className="mt-4" variant="error">{adminsError}</Alert> : null}
|
|
{adminMutationError ? <Alert className="mt-4" variant="error">{adminMutationError}</Alert> : null}
|
|
{adminMutationFeedback ? <Alert className="mt-4" variant="success">{adminMutationFeedback}</Alert> : null}
|
|
|
|
<div className="mt-4 space-y-3">
|
|
{adminsLoading && admins.length === 0 ? <LoadingState message="Loading admin identities..." /> : null}
|
|
{admins.map((admin) => {
|
|
const isMutatingStatus = statusMutationAdminId === admin.id;
|
|
const isActive = admin.status === 'active';
|
|
return (
|
|
<div key={admin.id} className="rounded-2xl border border-stone-200 p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="font-semibold text-stone-900">{admin.email}</p>
|
|
<p className="text-xs text-stone-500">Created {formatDateLabel(admin.createdAt)}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={isActive ? 'success' : 'warning'}>{admin.status}</Badge>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={isActive ? 'subtle' : 'secondary'}
|
|
onClick={() => void handleSetAdminStatus(admin.id, isActive ? 'disabled' : 'active')}
|
|
disabled={isMutatingStatus}
|
|
>
|
|
{isMutatingStatus ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
|
{isActive ? 'Disable' : 'Re-enable'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{!adminsLoading && admins.length === 0 ? (
|
|
<p className="rounded-2xl border border-dashed border-stone-200 bg-stone-50 px-4 py-5 text-sm text-stone-600">
|
|
No application admins are configured.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
|
|
<ShieldAlert className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Security Posture</p>
|
|
<h2 className="text-lg font-semibold text-stone-950">Bootstrap and admin-allowlist checks</h2>
|
|
</div>
|
|
</div>
|
|
<Button type="button" variant="secondary" onClick={() => void loadSecurityPosture()} disabled={securityLoading}>
|
|
{securityLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{securityError ? <Alert className="mt-4" variant="error">{securityError}</Alert> : null}
|
|
{securityLoading && !securityPosture ? <LoadingState message="Loading security posture..." /> : null}
|
|
|
|
{securityPosture ? (
|
|
<div className="mt-4 space-y-4">
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
|
<div className="rounded-xl bg-stone-50 p-3">
|
|
<p className="text-xs uppercase tracking-[0.14em] text-stone-500">Bootstrap Required</p>
|
|
<p className="mt-1 font-semibold text-stone-900">{securityPosture.bootstrapRequired ? 'Yes' : 'No'}</p>
|
|
</div>
|
|
<div className="rounded-xl bg-stone-50 p-3">
|
|
<p className="text-xs uppercase tracking-[0.14em] text-stone-500">Bootstrap Enabled</p>
|
|
<p className="mt-1 font-semibold text-stone-900">{securityPosture.bootstrapEnabled ? 'Yes' : 'No'}</p>
|
|
</div>
|
|
<div className="rounded-xl bg-stone-50 p-3">
|
|
<p className="text-xs uppercase tracking-[0.14em] text-stone-500">Active App Admins</p>
|
|
<p className="mt-1 font-semibold text-stone-900">{securityPosture.activeApplicationAdminCount}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{securityPosture.usingDeprecatedBillingAdminFallback ? (
|
|
<Alert variant="error" title="Deprecated allowlist fallback active">
|
|
<p>Set `ADMIN_EMAILS` and stop relying on `BILLING_ADMIN_EMAILS` fallback.</p>
|
|
</Alert>
|
|
) : null}
|
|
|
|
{!securityPosture.adminAllowlistConfigured ? (
|
|
<Alert variant="info" title="Admin allowlist not configured">
|
|
<p>Configure `ADMIN_EMAILS` for emergency access and recovery playbooks.</p>
|
|
</Alert>
|
|
) : null}
|
|
|
|
{securityPosture.bootstrapEnabled ? (
|
|
<Alert variant="error" title="Bootstrap still enabled">
|
|
<p>After first-run setup, disable bootstrap and rotate `ADMIN_BOOTSTRAP_TOKEN`.</p>
|
|
</Alert>
|
|
) : null}
|
|
|
|
<div className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-sm text-stone-700">
|
|
<p className="font-semibold text-stone-900">Hardening checklist</p>
|
|
<ul className="mt-2 space-y-1">
|
|
<li>- Disable bootstrap once initial admin setup is complete.</li>
|
|
<li>- Rotate the bootstrap token and store it in secrets management.</li>
|
|
<li>- Keep at least two active application admins to reduce lockout risk.</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
|
|
<ShieldAlert className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Support Diagnostics</p>
|
|
<h2 className="text-lg font-semibold text-stone-950">Webhook failures, sync health, and timeline anomalies</h2>
|
|
</div>
|
|
</div>
|
|
<Button type="button" variant="secondary" onClick={() => void loadDiagnostics()} disabled={diagnosticsLoading}>
|
|
{diagnosticsLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{diagnosticsError ? <Alert className="mt-4" variant="error">{diagnosticsError}</Alert> : null}
|
|
{diagnosticsLoading && !diagnostics ? <LoadingState message="Loading support diagnostics..." /> : null}
|
|
|
|
{diagnostics ? (
|
|
<div className="mt-4 space-y-4">
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
|
<DiagnosticsMetricCard title="Failed webhooks" value={diagnostics.failedWebhooks.count} />
|
|
<DiagnosticsMetricCard title="Stale sync accounts" value={diagnostics.staleBillingSync.count} />
|
|
<DiagnosticsMetricCard
|
|
title="Timeline anomalies"
|
|
value={
|
|
diagnostics.timelineAnomalies.repeatedPaymentFailedCount
|
|
+ diagnostics.timelineAnomalies.pendingPlanPastEffectiveCount
|
|
+ diagnostics.timelineAnomalies.staleSyncThresholdCount
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
|
<div className="rounded-2xl border border-stone-200 p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-sm font-semibold uppercase tracking-[0.14em] text-stone-500">Failed webhook samples</p>
|
|
<Badge>{diagnostics.failedWebhooks.count} total</Badge>
|
|
</div>
|
|
<div className="mt-3 space-y-2">
|
|
{diagnostics.failedWebhooks.items.length === 0 ? <p className="text-sm text-stone-600">No failed webhook events in window.</p> : null}
|
|
{diagnostics.failedWebhooks.items.map((issue) => (
|
|
<div key={issue.id} className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-sm">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="font-semibold text-stone-900">{issue.eventType}</p>
|
|
<p className="text-stone-600">{issue.workspaceName || 'Unknown workspace'} - {formatDateLabel(issue.receivedAt)}</p>
|
|
<p className="mt-1 text-xs text-stone-500">{issue.errorMessage || 'No error message provided.'}</p>
|
|
</div>
|
|
{issue.workspaceId ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => void handleLoadWorkspaceDetail(issue.workspaceId!)}
|
|
>
|
|
Open
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-stone-200 p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-sm font-semibold uppercase tracking-[0.14em] text-stone-500">Stale sync samples</p>
|
|
<Badge>{diagnostics.staleBillingSync.count} total</Badge>
|
|
</div>
|
|
<div className="mt-3 space-y-2">
|
|
{diagnostics.staleBillingSync.items.length === 0 ? <p className="text-sm text-stone-600">No stale sync accounts in window.</p> : null}
|
|
{diagnostics.staleBillingSync.items.map((issue) => (
|
|
<div key={issue.workspaceId} className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-sm">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="font-semibold text-stone-900">{issue.workspaceName}</p>
|
|
<p className="text-stone-600">{issue.planCode || 'No plan'} - {formatBillingStatusLabel(issue.status)}</p>
|
|
<p className="mt-1 text-xs text-stone-500">Last sync: {formatDateLabel(issue.lastStripeSyncAt)}</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => void handleLoadWorkspaceDetail(issue.workspaceId)}
|
|
>
|
|
Open
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-stone-200 p-4 text-sm">
|
|
<p className="font-semibold text-stone-900">Timeline anomaly counters ({diagnostics.windowDays}-day window)</p>
|
|
<div className="mt-2 grid grid-cols-1 gap-2 md:grid-cols-3">
|
|
<div className="rounded-xl bg-stone-50 p-3 text-stone-700">
|
|
Repeated payment failures: <span className="font-semibold text-stone-900">{diagnostics.timelineAnomalies.repeatedPaymentFailedCount}</span>
|
|
</div>
|
|
<div className="rounded-xl bg-stone-50 p-3 text-stone-700">
|
|
Pending plan past effective date: <span className="font-semibold text-stone-900">{diagnostics.timelineAnomalies.pendingPlanPastEffectiveCount}</span>
|
|
</div>
|
|
<div className="rounded-xl bg-stone-50 p-3 text-stone-700">
|
|
Stale sync threshold breaches: <span className="font-semibold text-stone-900">{diagnostics.timelineAnomalies.staleSyncThresholdCount}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
|
|
<ShieldCheck className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Admin Audit Explorer</p>
|
|
<h2 className="text-lg font-semibold text-stone-950">Route and support action history</h2>
|
|
</div>
|
|
</div>
|
|
<Button type="button" variant="secondary" onClick={() => void loadAuditLogs(auditPage)} disabled={auditLoading}>
|
|
{auditLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
|
|
<div>
|
|
<FieldLabel>Actor email</FieldLabel>
|
|
<Input value={auditActorEmail} onChange={(event) => setAuditActorEmail(event.target.value)} placeholder="ops@company.com" />
|
|
</div>
|
|
<div>
|
|
<FieldLabel>Action</FieldLabel>
|
|
<Select value={auditAction} onChange={(event) => setAuditAction(event.target.value as AdminAuditAction | '')}>
|
|
<option value="">All actions</option>
|
|
{AUDIT_ACTION_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>{option.label}</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<FieldLabel>Workspace ID</FieldLabel>
|
|
<Input value={auditWorkspaceId} onChange={(event) => setAuditWorkspaceId(event.target.value)} placeholder="uuid" />
|
|
</div>
|
|
<div>
|
|
<FieldLabel>From</FieldLabel>
|
|
<Input type="datetime-local" value={auditFrom} onChange={(event) => setAuditFrom(event.target.value)} />
|
|
</div>
|
|
<div>
|
|
<FieldLabel>To</FieldLabel>
|
|
<Input type="datetime-local" value={auditTo} onChange={(event) => setAuditTo(event.target.value)} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
void loadAuditLogs(1);
|
|
}}
|
|
disabled={auditLoading}
|
|
>
|
|
Apply filters
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="subtle"
|
|
onClick={() => {
|
|
setAuditActorEmail('');
|
|
setAuditAction('');
|
|
setAuditWorkspaceId('');
|
|
setAuditFrom('');
|
|
setAuditTo('');
|
|
void loadAuditLogs(1, {
|
|
actorEmail: '',
|
|
action: '',
|
|
workspaceId: '',
|
|
from: '',
|
|
to: '',
|
|
});
|
|
}}
|
|
disabled={auditLoading}
|
|
>
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
|
|
{auditError ? <Alert className="mt-4" variant="error">{auditError}</Alert> : null}
|
|
|
|
<div className="mt-4 overflow-x-auto">
|
|
{auditLoading && auditItems.length === 0 ? <LoadingState message="Loading admin audit logs..." /> : null}
|
|
{!auditLoading && auditItems.length === 0 ? (
|
|
<p className="rounded-2xl border border-dashed border-stone-200 bg-stone-50 px-4 py-5 text-sm text-stone-600">
|
|
No audit entries found for the selected filters.
|
|
</p>
|
|
) : null}
|
|
|
|
{auditItems.length > 0 ? (
|
|
<table className="w-full min-w-[920px] text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-stone-200 text-xs uppercase tracking-[0.14em] text-stone-500">
|
|
<th className="px-3 py-2">Occurred</th>
|
|
<th className="px-3 py-2">Actor</th>
|
|
<th className="px-3 py-2">Action</th>
|
|
<th className="px-3 py-2">Route</th>
|
|
<th className="px-3 py-2">Workspace</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{auditItems.map((item) => (
|
|
<tr key={item.id} className="border-b border-stone-100">
|
|
<td className="px-3 py-2 text-stone-700">{formatDateLabel(item.occurredAt)}</td>
|
|
<td className="px-3 py-2 text-stone-700">{item.actorEmail || 'Unknown actor'}</td>
|
|
<td className="px-3 py-2">
|
|
<span className="rounded-full bg-stone-100 px-2 py-1 text-xs font-semibold text-stone-700" title={item.action}>
|
|
{formatAuditActionLabel(item.action)}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-stone-700">{item.route}</td>
|
|
<td className="px-3 py-2 text-stone-700">{item.targetWorkspaceName || item.targetWorkspaceId || 'N/A'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm text-stone-600">
|
|
<p>
|
|
Showing page {auditPage} of {totalAuditPages} ({auditTotal} total entries)
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={auditLoading || auditPage <= 1}
|
|
onClick={() => {
|
|
void loadAuditLogs(auditPage - 1);
|
|
}}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={auditLoading || auditPage >= totalAuditPages}
|
|
onClick={() => {
|
|
void loadAuditLogs(auditPage + 1);
|
|
}}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
|
|
<BarChart3 className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Analytics Summary</p>
|
|
<h2 className="text-lg font-semibold text-stone-950">Admin metrics overview</h2>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-end gap-2">
|
|
<div>
|
|
<FieldLabel>Days</FieldLabel>
|
|
<Input
|
|
type="number"
|
|
min={MIN_SUMMARY_DAYS}
|
|
max={MAX_SUMMARY_DAYS}
|
|
value={summaryDays}
|
|
onChange={(event) => {
|
|
const numericValue = Number(event.target.value);
|
|
const normalizedValue = Number.isFinite(numericValue) ? numericValue : DEFAULT_SUMMARY_DAYS;
|
|
setSummaryDays(clampSummaryDays(normalizedValue));
|
|
}}
|
|
className="w-24"
|
|
/>
|
|
</div>
|
|
<Button type="button" variant="secondary" onClick={() => void handleRefreshSummary()} disabled={summaryLoading}>
|
|
{summaryLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
{summaryError ? <Alert variant="error">{summaryError}</Alert> : null}
|
|
{summaryLoading && !analyticsSummary ? <LoadingState message="Loading analytics summary..." /> : null}
|
|
{analyticsSummary ? (
|
|
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
|
<MetricBucketCard title="Plan mix" buckets={analyticsSummary.planMix} />
|
|
<MetricBucketCard title="Churn signals" buckets={analyticsSummary.churnSignals} />
|
|
<MetricBucketCard title="Expansion signals" buckets={analyticsSummary.expansionSignals} />
|
|
<MetricBucketCard title="Pricing conversion by plan" buckets={analyticsSummary.pricingConversionByPlan} />
|
|
<MetricBucketCard title="Quota exhaustion events" buckets={analyticsSummary.quotaExhaustionEvents} />
|
|
<MetricBucketCard title="Upgrade triggers" buckets={analyticsSummary.upgradeTriggers} />
|
|
<MetricBucketCard title="Add-on attach" buckets={analyticsSummary.addonAttach} />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
|
|
<ShieldCheck className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Billing Support Visibility</p>
|
|
<h2 className="text-lg font-semibold text-stone-950">Workspace lookup and detail</h2>
|
|
</div>
|
|
</div>
|
|
{detailLoading ? <Loader2 className="h-4 w-4 animate-spin text-stone-500" /> : null}
|
|
</div>
|
|
|
|
<div className="mt-4 flex gap-3">
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(event) => setSearchQuery(event.target.value)}
|
|
placeholder="Search workspace, customer ref, or subscription ref"
|
|
/>
|
|
<Button type="button" variant="secondary" onClick={() => void handleSearchWorkspaces()} disabled={workspacesLoading}>
|
|
<Search className="h-4 w-4" />
|
|
Search
|
|
</Button>
|
|
</div>
|
|
|
|
{workspacesError ? <Alert className="mt-4" variant="error">{workspacesError}</Alert> : null}
|
|
|
|
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
|
<div className="space-y-3">
|
|
{workspacesLoading && workspaces.length === 0 ? <LoadingState message="Loading billing workspaces..." /> : null}
|
|
{workspaces.map((workspace) => (
|
|
<button
|
|
key={workspace.workspaceId}
|
|
type="button"
|
|
onClick={() => void handleLoadWorkspaceDetail(workspace.workspaceId)}
|
|
className="w-full rounded-2xl border border-stone-200 p-4 text-left transition hover:border-stone-300 hover:bg-stone-50"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="font-semibold text-stone-900">{workspace.workspaceName}</p>
|
|
<p className="text-xs text-stone-500">{workspace.planCode || 'No plan'} - {formatBillingStatusLabel(workspace.status)}</p>
|
|
</div>
|
|
<Badge variant={workspace.billingSyncStatus === 'error' ? 'danger' : workspace.billingSyncStatus === 'stale' ? 'warning' : 'success'}>
|
|
{workspace.billingSyncStatus}
|
|
</Badge>
|
|
</div>
|
|
</button>
|
|
))}
|
|
{!workspacesLoading && workspaces.length === 0 ? (
|
|
<p className="rounded-2xl border border-dashed border-stone-200 bg-stone-50 px-4 py-5 text-sm text-stone-600">
|
|
No workspaces found for this search.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4">
|
|
{!selectedWorkspace ? (
|
|
<p className="text-sm text-stone-600">Select a workspace to review billing state, pending plan fields, and recent timeline events.</p>
|
|
) : (
|
|
<div className="space-y-4 text-sm">
|
|
<div>
|
|
<p className="font-semibold text-stone-900">{selectedWorkspace.summary.workspaceName}</p>
|
|
<p className="text-stone-600">{selectedWorkspace.summary.planCode || 'No plan'} - {formatBillingStatusLabel(selectedWorkspace.summary.status)}</p>
|
|
</div>
|
|
<div className="grid gap-2 text-stone-700">
|
|
<div>Period ends: <span className="font-medium text-stone-900">{formatDateLabel(selectedWorkspace.billing.currentPeriodEndsAt)}</span></div>
|
|
<div>Grace period ends: <span className="font-medium text-stone-900">{formatDateLabel(selectedWorkspace.billing.gracePeriodEndsAt)}</span></div>
|
|
<div>Pending plan code: <span className="font-medium text-stone-900">{selectedWorkspace.billing.pendingPlanCode || 'None'}</span></div>
|
|
<div>Pending effective at: <span className="font-medium text-stone-900">{formatDateLabel(selectedWorkspace.billing.pendingPlanEffectiveAt)}</span></div>
|
|
<div>External customer ref: <span className="font-medium text-stone-900">{selectedWorkspace.summary.externalCustomerRef || 'Not set'}</span></div>
|
|
<div>External subscription ref: <span className="font-medium text-stone-900">{selectedWorkspace.summary.externalSubscriptionRef || 'Not set'}</span></div>
|
|
<div>Usage period id: <span className="font-medium text-stone-900">{selectedWorkspace.usagePeriodId || 'Not active'}</span></div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="font-semibold text-stone-900">Recent timeline summary</p>
|
|
<div className="mt-2 space-y-2">
|
|
{selectedWorkspace.timeline.slice(0, 10).map((entry) => (
|
|
<div key={entry.id} className="rounded-xl border border-stone-200 bg-white px-3 py-2">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="font-medium text-stone-900">{entry.eventType}</span>
|
|
<span className="text-xs text-stone-500">{formatDateLabel(entry.occurredAt)}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{selectedWorkspace.timeline.length === 0 ? <p className="text-stone-600">No timeline events recorded.</p> : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</PageContainer>
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
function DiagnosticsMetricCard({ title, value }: { title: string; value: number }) {
|
|
return (
|
|
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
|
<p className="text-xs uppercase tracking-[0.14em] text-stone-500">{title}</p>
|
|
<p className="mt-1 text-2xl font-semibold tracking-tight text-stone-900">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricBucketCard({ title, buckets }: { title: string; buckets: Array<{ key: string; count: number }> }) {
|
|
return (
|
|
<div className="rounded-2xl border border-stone-200 p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-sm font-semibold uppercase tracking-[0.14em] text-stone-500">{title}</p>
|
|
<Badge>{buckets.reduce((sum, bucket) => sum + bucket.count, 0)} total</Badge>
|
|
</div>
|
|
<div className="mt-3 space-y-2">
|
|
{buckets.length === 0 ? <p className="text-sm text-stone-500">No data for this window.</p> : null}
|
|
{buckets.slice(0, 6).map((bucket) => (
|
|
<div key={bucket.key} className="flex items-center justify-between gap-3 rounded-xl bg-stone-50 px-3 py-2 text-sm">
|
|
<span className="truncate text-stone-700">{bucket.key}</span>
|
|
<span className="font-semibold text-stone-900">{bucket.count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|