WS3 — Advanced Analytics Dashboard: - DB migration: analytics_events table (tenant_id, date, metric_type, count) - AnalyticsService: recordEvent (fire-and-forget), getTokenTrend, getAgentActivity, getAgentUsageSummary - Analytics hooks in OAuth2Service (token_issued) and AgentService (agent_registered/deactivated) - AnalyticsController + routes/analytics.ts (gated by ANALYTICS_ENABLED flag) - Portal: TokenTrendChart (recharts LineChart), AgentHeatmap (recharts heatmap), /analytics page WS4 — API Gateway Tiers: - DB migration: tenant_tiers table; src/config/tiers.ts (free/pro/enterprise limits) - TierService: getStatus, initiateUpgrade (Stripe), applyUpgrade; TierLimitError in errors.ts - tierEnforcement middleware (Redis-backed daily call/token counters; TIER_ENFORCEMENT flag) - Agent count enforcement in AgentService.create() - Stripe webhook updated to call TierService.applyUpgrade() on checkout.session.completed - TierController + routes/tiers.ts; Portal: /settings/tier page with upgrade flow WS6 — AGNTCY Compliance Certification: - ComplianceService: generateReport() (Redis-cached 5 min), exportAgentCards() - Compliance sections: agent-identity (DID + credential expiry checks), audit-trail (Merkle chain) - ComplianceController updated with getComplianceReport, exportAgentCards handlers - routes/compliance.ts: new AGNTCY routes (gated by COMPLIANCE_ENABLED flag); SOC2 routes unaffected QA: - 28 new unit tests: AnalyticsService (8), TierService (9), ComplianceService (11) — all pass - 673 total unit tests passing; 0 TypeScript errors across API and portal - AGNTCY conformance test suite at tests/agntcy-conformance/ (4 protocol tests) - Portal builds cleanly: 9 routes including /analytics and /settings/tier - Feature flags verified: ANALYTICS_ENABLED, TIER_ENFORCEMENT, COMPLIANCE_ENABLED Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* AnalyticsPage — Tenant analytics dashboard for the SentryAgent portal.
|
|
*
|
|
* Displays:
|
|
* - Token issuance trend (last 30 days) via TokenTrendChart (lazy-loaded)
|
|
* - Agent activity by day-of-week via AgentHeatmap (lazy-loaded)
|
|
* - Per-agent usage table for the current billing period
|
|
*
|
|
* All chart components are code-split via `next/dynamic` so that recharts
|
|
* is excluded from the main bundle.
|
|
*
|
|
* Protected route: redirects to `/login` when no JWT is present (via useAuth).
|
|
*
|
|
* @module app/analytics/page
|
|
*/
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
import dynamic from 'next/dynamic';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import type { TokenTrendDataPoint } from '@/components/charts/TokenTrendChart';
|
|
import type { AgentActivityBucket } from '@/components/charts/AgentHeatmap';
|
|
|
|
/* -------------------------------------------------------------------------
|
|
* Lazy-loaded chart components (recharts stays out of the main bundle)
|
|
* ---------------------------------------------------------------------- */
|
|
|
|
const TokenTrendChart = dynamic(
|
|
() => import('@/components/charts/TokenTrendChart'),
|
|
{
|
|
ssr: false,
|
|
loading: () => (
|
|
<ChartSkeleton label="Loading token trend chart…" height={300} />
|
|
),
|
|
},
|
|
);
|
|
|
|
const AgentHeatmap = dynamic(
|
|
() => import('@/components/charts/AgentHeatmap'),
|
|
{
|
|
ssr: false,
|
|
loading: () => (
|
|
<ChartSkeleton label="Loading activity chart…" height={300} />
|
|
),
|
|
},
|
|
);
|
|
|
|
/* -------------------------------------------------------------------------
|
|
* API response types
|
|
* ---------------------------------------------------------------------- */
|
|
|
|
/** A single entry from `GET /api/analytics/agents`. */
|
|
interface AgentUsageSummary {
|
|
agent_id: string;
|
|
name: string;
|
|
token_count: number;
|
|
}
|
|
|
|
/** Root shape of `GET /api/analytics/agents/activity`. */
|
|
type AgentActivityResponse = AgentActivityBucket[];
|
|
|
|
/** Root shape of `GET /api/analytics/tokens`. */
|
|
type TokenTrendResponse = TokenTrendDataPoint[];
|
|
|
|
/** Root shape of `GET /api/analytics/agents`. */
|
|
type AgentUsageResponse = AgentUsageSummary[];
|
|
|
|
/* -------------------------------------------------------------------------
|
|
* Page state
|
|
* ---------------------------------------------------------------------- */
|
|
|
|
/** Loading / error / data state for a single async fetch. */
|
|
interface FetchState<T> {
|
|
data: T | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
function initFetchState<T>(): FetchState<T> {
|
|
return { data: null, loading: true, error: null };
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------
|
|
* Helpers
|
|
* ---------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Fetches a JSON endpoint with an Authorization bearer token.
|
|
*
|
|
* @param url - Absolute URL to fetch
|
|
* @param token - JWT bearer token
|
|
* @returns Parsed JSON of type T
|
|
* @throws Error with a descriptive message on non-2xx or network failure
|
|
*/
|
|
async function fetchWithAuth<T>(url: string, token: string): Promise<T> {
|
|
const res = await fetch(url, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (!res.ok) {
|
|
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
|
throw new Error(body.message ?? `Request failed: ${res.status}`);
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------
|
|
* Sub-components
|
|
* ---------------------------------------------------------------------- */
|
|
|
|
/** Props for ChartSkeleton. */
|
|
interface ChartSkeletonProps {
|
|
label: string;
|
|
height: number;
|
|
}
|
|
|
|
/**
|
|
* Placeholder shown while a dynamic chart chunk is loading.
|
|
*
|
|
* @param props - ChartSkeletonProps
|
|
* @returns JSX element
|
|
*/
|
|
function ChartSkeleton({ label, height }: ChartSkeletonProps): React.ReactElement {
|
|
return (
|
|
<div
|
|
className="flex animate-pulse items-center justify-center rounded-xl bg-slate-100 text-sm text-slate-400"
|
|
style={{ height }}
|
|
aria-label={label}
|
|
>
|
|
{label}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Props for SectionCard. */
|
|
interface SectionCardProps {
|
|
title: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Simple card wrapper for dashboard sections.
|
|
*
|
|
* @param props - SectionCardProps
|
|
* @returns JSX element
|
|
*/
|
|
function SectionCard({ title, children }: SectionCardProps): React.ReactElement {
|
|
return (
|
|
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
|
<h2 className="mb-4 text-lg font-semibold text-slate-900">{title}</h2>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Props for ErrorBanner. */
|
|
interface ErrorBannerProps {
|
|
message: string;
|
|
}
|
|
|
|
/**
|
|
* Inline error banner for failed data fetches.
|
|
*
|
|
* @param props - ErrorBannerProps
|
|
* @returns JSX element
|
|
*/
|
|
function ErrorBanner({ message }: ErrorBannerProps): React.ReactElement {
|
|
return (
|
|
<p className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
{message}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------
|
|
* Page component
|
|
* ---------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Renders the analytics dashboard page.
|
|
*
|
|
* Checks authentication via `useAuth` (redirects to /login if no token).
|
|
* Fetches analytics data from the AgentIdP API using the stored JWT.
|
|
*
|
|
* @returns JSX element
|
|
*/
|
|
export default function AnalyticsPage(): React.ReactElement {
|
|
const { token, loading: authLoading } = useAuth(true);
|
|
|
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
|
|
|
|
const [tokenTrend, setTokenTrend] =
|
|
useState<FetchState<TokenTrendResponse>>(initFetchState);
|
|
const [agentActivity, setAgentActivity] =
|
|
useState<FetchState<AgentActivityResponse>>(initFetchState);
|
|
const [agentUsage, setAgentUsage] =
|
|
useState<FetchState<AgentUsageResponse>>(initFetchState);
|
|
|
|
useEffect(() => {
|
|
// Wait until auth state is resolved and token is available
|
|
if (authLoading || token === null) return;
|
|
|
|
void fetchWithAuth<TokenTrendResponse>(
|
|
`${apiUrl}/api/analytics/tokens?days=30`,
|
|
token,
|
|
)
|
|
.then((data) => setTokenTrend({ data, loading: false, error: null }))
|
|
.catch((err: unknown) =>
|
|
setTokenTrend({
|
|
data: null,
|
|
loading: false,
|
|
error: err instanceof Error ? err.message : 'Failed to load token trend',
|
|
}),
|
|
);
|
|
|
|
void fetchWithAuth<AgentActivityResponse>(
|
|
`${apiUrl}/api/analytics/agents/activity`,
|
|
token,
|
|
)
|
|
.then((data) => setAgentActivity({ data, loading: false, error: null }))
|
|
.catch((err: unknown) =>
|
|
setAgentActivity({
|
|
data: null,
|
|
loading: false,
|
|
error: err instanceof Error ? err.message : 'Failed to load agent activity',
|
|
}),
|
|
);
|
|
|
|
void fetchWithAuth<AgentUsageResponse>(
|
|
`${apiUrl}/api/analytics/agents`,
|
|
token,
|
|
)
|
|
.then((data) => setAgentUsage({ data, loading: false, error: null }))
|
|
.catch((err: unknown) =>
|
|
setAgentUsage({
|
|
data: null,
|
|
loading: false,
|
|
error: err instanceof Error ? err.message : 'Failed to load agent usage',
|
|
}),
|
|
);
|
|
}, [authLoading, token, apiUrl]);
|
|
|
|
// While auth state is being resolved, show a full-page loading state
|
|
if (authLoading) {
|
|
return (
|
|
<div className="flex min-h-[60vh] items-center justify-center">
|
|
<p className="text-slate-500">Loading…</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// token === null means useAuth is redirecting to /login; render nothing
|
|
if (token === null) {
|
|
return <></>;
|
|
}
|
|
|
|
return (
|
|
<div className="px-6 py-16">
|
|
<div className="mx-auto max-w-6xl">
|
|
{/* Page header */}
|
|
<div className="mb-10">
|
|
<h1 className="text-4xl font-extrabold text-slate-900">Analytics</h1>
|
|
<p className="mt-2 text-slate-600">
|
|
Token issuance and agent activity for the last 30 days
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-8">
|
|
{/* Token Trend */}
|
|
<SectionCard title="Token Issuance Trend (Last 30 Days)">
|
|
{tokenTrend.error !== null && (
|
|
<ErrorBanner message={tokenTrend.error} />
|
|
)}
|
|
{tokenTrend.loading && tokenTrend.error === null && (
|
|
<ChartSkeleton label="Loading token trend chart…" height={300} />
|
|
)}
|
|
{tokenTrend.data !== null && (
|
|
<TokenTrendChart data={tokenTrend.data} />
|
|
)}
|
|
</SectionCard>
|
|
|
|
{/* Agent Activity Heatmap */}
|
|
<SectionCard title="Agent Activity by Day of Week (Last 30 Days)">
|
|
{agentActivity.error !== null && (
|
|
<ErrorBanner message={agentActivity.error} />
|
|
)}
|
|
{agentActivity.loading && agentActivity.error === null && (
|
|
<ChartSkeleton label="Loading activity chart…" height={300} />
|
|
)}
|
|
{agentActivity.data !== null && (
|
|
<AgentHeatmap data={agentActivity.data} />
|
|
)}
|
|
</SectionCard>
|
|
|
|
{/* Per-Agent Usage Table */}
|
|
<SectionCard title="Per-Agent Usage (Current Month)">
|
|
{agentUsage.error !== null && (
|
|
<ErrorBanner message={agentUsage.error} />
|
|
)}
|
|
{agentUsage.loading && agentUsage.error === null && (
|
|
<div className="animate-pulse space-y-2">
|
|
{[1, 2, 3].map((n) => (
|
|
<div key={n} className="h-10 rounded bg-slate-100" />
|
|
))}
|
|
</div>
|
|
)}
|
|
{agentUsage.data !== null && agentUsage.data.length === 0 && (
|
|
<p className="text-sm text-slate-500">
|
|
No agents have issued tokens this month.
|
|
</p>
|
|
)}
|
|
{agentUsage.data !== null && agentUsage.data.length > 0 && (
|
|
<div className="overflow-hidden rounded-xl border border-slate-200">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-slate-50">
|
|
<th className="px-4 py-3 text-left font-semibold text-slate-700">
|
|
Agent
|
|
</th>
|
|
<th className="px-4 py-3 text-right font-semibold text-slate-700">
|
|
Tokens Issued
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{agentUsage.data.map(({ agent_id, name, token_count }, i) => (
|
|
<tr
|
|
key={agent_id}
|
|
className={i % 2 === 0 ? 'bg-white' : 'bg-slate-50'}
|
|
>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
<span className="font-medium">{name}</span>
|
|
<span className="ml-2 font-mono text-xs text-slate-400">
|
|
{agent_id}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-right font-medium tabular-nums text-slate-900">
|
|
{token_count.toLocaleString()}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</SectionCard>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|