Skip to content

Commit e514fdb

Browse files
authored
Merge pull request #33 from reclaimprotocol/feat/x402-admin-analytics
feat: surface x402 payment analytics in admin UI
2 parents 287174f + 670ef73 commit e514fdb

4 files changed

Lines changed: 216 additions & 7 deletions

File tree

services/control-plane/index.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ import {
5353
type ActionNotice,
5454
type AdminRegion,
5555
type AnalyticsData,
56+
type AnalyticsScope,
57+
type X402AnalyticsData,
5658
} from './src/admin-ui';
5759

5860
const app = new Hono();
@@ -722,6 +724,22 @@ function bucketsForWindow(windowHours: number): number {
722724

723725
// Combines live Agones gauges (via pool managers) with cumulative Postgres
724726
// session stats into a single payload shared by the API and the admin UI.
727+
// USDC and the other supported x402 settlement assets use 6 decimals; the
728+
// client flow assumes the same. The atomic ledger stays the source of truth.
729+
const X402_ASSET_DECIMALS = 6;
730+
731+
async function buildX402AnalyticsPayload(windowHours: number): Promise<X402AnalyticsData | undefined> {
732+
if (!ControlPlaneConfig.x402.enabled) return undefined;
733+
const analytics = await getX402Analytics(windowHours, ControlPlaneConfig.x402.blockSeconds);
734+
return {
735+
...analytics,
736+
assetName: ControlPlaneConfig.x402.paymentAssetName,
737+
assetDecimals: X402_ASSET_DECIMALS,
738+
network: ControlPlaneConfig.x402.network,
739+
testnet: ControlPlaneConfig.x402.network === 'eip155:84532',
740+
};
741+
}
742+
725743
async function buildStatsPayload(windowHours: number): Promise<AnalyticsData> {
726744
const [regions, windowStats, allocationStats, activeSessions, staleActiveSessions, series, regionSessions, topClients] = await Promise.all([
727745
loadAdminRegions(),
@@ -1210,8 +1228,12 @@ app.get('/admin/ui/clusters', async (c) => {
12101228

12111229
app.get('/admin/ui/analytics', async (c) => {
12121230
const windowHours = normalizeWindowHours(c.req.query('windowHours') ?? undefined);
1213-
const data = await buildStatsPayload(windowHours);
1214-
return c.html(await renderAnalyticsViewHtml({ data }));
1231+
const scope: AnalyticsScope = c.req.query('scope') === 'x402' ? 'x402' : 'fleet';
1232+
const [data, x402] = await Promise.all([
1233+
buildStatsPayload(windowHours),
1234+
buildX402AnalyticsPayload(windowHours),
1235+
]);
1236+
return c.html(await renderAnalyticsViewHtml({ data, x402, scope }));
12151237
});
12161238

12171239
app.post('/admin/ui/sessions', async (c) => {

services/control-plane/public/admin.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,14 @@ small, .muted { color: var(--text-soft); }
459459
.an-controls { align-items: center; display: flex; gap: .55rem; }
460460
.an-range { min-width: 150px; width: auto; }
461461
.an-refresh { background: var(--surface-raised); border-color: var(--line); color: var(--text); }
462+
.an-scope { align-items: center; background: var(--surface); border: 1px solid var(--line); border-radius: 9px; box-shadow: inset 0 1px 2px rgba(0,0,0,.22); display: inline-flex; gap: 2px; padding: 3px; }
463+
.an-scope-btn { align-items: center; background: transparent; border: 0; border-radius: 6px; color: var(--text-soft); cursor: pointer; display: inline-flex; font-size: .8rem; font-weight: 600; gap: .42rem; line-height: 1.2; padding: .4rem .8rem; transition: background .14s ease, color .14s ease, box-shadow .14s ease; }
464+
.an-scope-btn:hover:not(.active) { background: rgba(255,255,255,.045); color: var(--text); }
465+
.an-scope-btn.active { background: var(--surface-raised); box-shadow: 0 1px 2px rgba(0,0,0,.3), 0 0 0 1px var(--line-strong); color: var(--text); }
466+
.an-scope-dot { border-radius: 50%; flex: none; height: 7px; opacity: .45; transition: opacity .14s ease; width: 7px; }
467+
.an-scope-btn.active .an-scope-dot { opacity: 1; }
468+
.an-scope-dot.fleet { background: var(--blue); }
469+
.an-scope-dot.x402 { background: #35c680; }
462470
.an-sublabel { color: var(--text-faint); font-size: .68rem; font-weight: 800; letter-spacing: .1em; margin: 1.4rem 0 .65rem; text-transform: uppercase; }
463471
.an-sublabel:first-of-type { margin-top: 0; }
464472
.an-kpis { display: grid; gap: .7rem; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); }
@@ -617,6 +625,8 @@ small, .muted { color: var(--text-soft); }
617625
.inventory-count { align-items: center; display: flex; justify-content: center; }
618626
.pod-inventory-scroll { max-height: none; }
619627
.an-controls { align-items: stretch; flex-direction: column; width: 100%; }.an-range { width: 100%; }
628+
.an-scope { width: 100%; }
629+
.an-scope-btn { flex: 1 1 0; justify-content: center; }
620630
.ops-table, .ops-table tbody { display: block; min-width: 0; }
621631
.ops-table thead { display: none; }
622632
.ops-table tbody tr { background: var(--surface-soft); border: 1px solid var(--line); border-radius: var(--radius-sm); display: block; margin: .65rem; padding: .7rem .8rem; }

services/control-plane/src/admin-ui.tsx

Lines changed: 169 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,30 @@ export interface AnalyticsData {
9494
series: AnalyticsSeriesPoint[];
9595
}
9696

97+
export type AnalyticsScope = 'fleet' | 'x402';
98+
99+
export interface X402OperationStat {
100+
operation: string;
101+
payments: number;
102+
revenueAtomic: string;
103+
paidSeconds: number;
104+
}
105+
106+
export interface X402AnalyticsData {
107+
windowHours: number;
108+
liveSessions: number;
109+
revenueAtomic: string;
110+
settledPayments: number;
111+
uniquePayers: number;
112+
paidSeconds: number;
113+
operations: X402OperationStat[];
114+
events: Record<string, number>;
115+
assetName: string;
116+
assetDecimals: number;
117+
network: string;
118+
testnet: boolean;
119+
}
120+
97121
export interface ClientSecretNotice {
98122
clientId: string;
99123
clientSecret: string;
@@ -1451,26 +1475,58 @@ const ANALYTICS_STYLE = `
14511475
.bar-fill.green { background: linear-gradient(90deg, #17915a, #35c680); }
14521476
`;
14531477

1454-
export function AnalyticsView({ data }: { data: AnalyticsData }) {
1478+
function AnalyticsScopeToggle({ scope, windowHours }: { scope: AnalyticsScope; windowHours: number }) {
1479+
const options: { value: AnalyticsScope; label: string }[] = [
1480+
{ value: 'fleet', label: 'Fleet' },
1481+
{ value: 'x402', label: 'x402' },
1482+
];
1483+
return (
1484+
<div class="an-scope" role="tablist" aria-label="Analytics scope">
1485+
{options.map((option) => (
1486+
<button
1487+
type="button"
1488+
role="tab"
1489+
class={`an-scope-btn${option.value === scope ? ' active' : ''}`}
1490+
aria-selected={option.value === scope ? 'true' : 'false'}
1491+
hx-get={`/admin/ui/analytics?scope=${option.value}&windowHours=${windowHours}`}
1492+
hx-target="#admin-content"
1493+
hx-swap="innerHTML"
1494+
>
1495+
<span class={`an-scope-dot ${option.value}`} aria-hidden="true"></span>
1496+
<span>{option.label}</span>
1497+
</button>
1498+
))}
1499+
</div>
1500+
);
1501+
}
1502+
1503+
export function AnalyticsView({ data, x402, scope = 'fleet' }: { data: AnalyticsData; x402?: X402AnalyticsData; scope?: AnalyticsScope }) {
14551504
const { live, window, series } = data;
14561505
const utilization = percent(live.allocated, live.capacity);
14571506
const free = Math.max(0, live.capacity - live.allocated - live.ready);
14581507
const hasActivity = series.some((p) => p.ended > 0 || p.created > 0);
1508+
// The toggle only exists when x402 is enabled; otherwise always show fleet.
1509+
const activeScope: AnalyticsScope = x402 && scope === 'x402' ? 'x402' : 'fleet';
14591510

14601511
return (
14611512
<div class="workspace analytics-workspace">
14621513
<header class="page-header an-topbar">
14631514
<div class="an-title">
14641515
<span class="eyebrow">Performance</span>
14651516
<h1>Analytics</h1>
1466-
<p>Fleet allocation and session lifecycle across the {windowLabel(data.windowHours)}.</p>
1517+
<p>
1518+
{activeScope === 'x402'
1519+
? `Paid x402 sessions across the ${windowLabel(data.windowHours)}.`
1520+
: `Fleet allocation and session lifecycle across the ${windowLabel(data.windowHours)}.`}
1521+
</p>
14671522
</div>
14681523
<div class="an-controls">
1524+
{x402 ? <AnalyticsScopeToggle scope={activeScope} windowHours={data.windowHours} /> : null}
14691525
<select
14701526
class="an-range"
14711527
name="windowHours"
14721528
aria-label="Time range"
1473-
hx-get="/admin/ui/analytics"
1529+
hx-get={`/admin/ui/analytics?scope=${activeScope}`}
14741530
hx-target="#admin-content"
14751531
hx-swap="innerHTML"
14761532
hx-trigger="change"
@@ -1482,7 +1538,7 @@ export function AnalyticsView({ data }: { data: AnalyticsData }) {
14821538
<button
14831539
type="button"
14841540
class="an-refresh"
1485-
hx-get={`/admin/ui/analytics?windowHours=${data.windowHours}`}
1541+
hx-get={`/admin/ui/analytics?windowHours=${data.windowHours}&scope=${activeScope}`}
14861542
hx-target="#admin-content"
14871543
hx-swap="innerHTML"
14881544
>
@@ -1491,6 +1547,8 @@ export function AnalyticsView({ data }: { data: AnalyticsData }) {
14911547
</div>
14921548
</header>
14931549

1550+
{activeScope === 'x402' && x402 ? <X402Section x402={x402} /> : (
1551+
<>
14941552
<div class="an-sublabel">Live fleet</div>
14951553
<div class="an-kpis">
14961554
<AnKpi label="Allocated" value={String(live.allocated)} hint={`of ${live.capacity} · ${utilization}%`} tone="accent" />
@@ -1569,10 +1627,117 @@ export function AnalyticsView({ data }: { data: AnalyticsData }) {
15691627
</div>
15701628
</div>
15711629
)}
1630+
</>
1631+
)}
15721632
</div>
15731633
);
15741634
}
15751635

1636+
// USDC and most x402 settlement assets use 6 decimals; the client flow assumes
1637+
// the same. Format the atomic ledger amount into a human token value.
1638+
function formatAtomicAmount(atomic: string, decimals: number) {
1639+
const digits = String(atomic || '0').replace(/[^0-9]/g, '') || '0';
1640+
if (decimals <= 0) return digits;
1641+
const padded = digits.padStart(decimals + 1, '0');
1642+
const whole = padded.slice(0, padded.length - decimals);
1643+
const fraction = padded.slice(padded.length - decimals).replace(/0+$/, '');
1644+
return fraction ? `${whole}.${fraction}` : whole;
1645+
}
1646+
1647+
function x402EventLabel(eventType: string) {
1648+
return eventType
1649+
.replace(/^x402\./, '')
1650+
.replace(/_/g, ' ')
1651+
.replace(/^\w/, (ch) => ch.toUpperCase());
1652+
}
1653+
1654+
function X402Section({ x402 }: { x402: X402AnalyticsData }) {
1655+
const revenue = formatAtomicAmount(x402.revenueAtomic, x402.assetDecimals);
1656+
const events = Object.entries(x402.events).sort((a, b) => b[1] - a[1]);
1657+
const created = x402.events['x402.session_created'] || 0;
1658+
const challenges = x402.events['x402.challenge_issued'] || 0;
1659+
const conversion = challenges > 0 ? Math.round((created / challenges) * 100) : 0;
1660+
1661+
return (
1662+
<>
1663+
<div class="an-sublabel">
1664+
x402 paid sessions · {windowLabel(x402.windowHours)}
1665+
{x402.testnet ? ' · testnet' : ''} · {x402.network}
1666+
</div>
1667+
<div class="an-kpis">
1668+
<AnKpi
1669+
label="Live sessions"
1670+
value={String(x402.liveSessions)}
1671+
hint="paid access active now"
1672+
tone={x402.liveSessions > 0 ? 'success' : undefined}
1673+
/>
1674+
<AnKpi
1675+
label="Revenue"
1676+
value={`${revenue} ${x402.assetName}`}
1677+
hint={`${x402.settledPayments} settled payment${x402.settledPayments === 1 ? '' : 's'}`}
1678+
tone="accent"
1679+
/>
1680+
<AnKpi label="Unique payers" value={String(x402.uniquePayers)} hint="distinct wallets" />
1681+
<AnKpi label="Paid time sold" value={formatDuration(x402.paidSeconds)} />
1682+
<AnKpi
1683+
label="Challenge → session"
1684+
value={challenges > 0 ? `${conversion}%` : '—'}
1685+
hint={challenges > 0 ? `${created} of ${challenges} challenges` : 'No challenges issued'}
1686+
tone={challenges > 0 && conversion >= 50 ? 'success' : undefined}
1687+
/>
1688+
</div>
1689+
1690+
<div class="an-charts">
1691+
<div class="an-card">
1692+
<div class="an-card-head"><h3>Revenue by operation</h3><span class="an-card-sub">create vs extend</span></div>
1693+
{x402.operations.length ? (
1694+
<div class="bar-list">
1695+
{x402.operations.map((op) => (
1696+
<div class="bar-row">
1697+
<div class="bar-row-head">
1698+
<span class="bar-row-label">{op.operation}</span>
1699+
<span class="bar-row-value">
1700+
{formatAtomicAmount(op.revenueAtomic, x402.assetDecimals)} {x402.assetName} · {op.payments} payment{op.payments === 1 ? '' : 's'} · {formatDuration(op.paidSeconds)}
1701+
</span>
1702+
</div>
1703+
<div class="bar-track">
1704+
<div
1705+
class="bar-fill blue"
1706+
style={`width:${percent(op.payments, x402.settledPayments)}%`}
1707+
></div>
1708+
</div>
1709+
</div>
1710+
))}
1711+
</div>
1712+
) : (
1713+
<div class="viz-empty">No settled payments in this range.</div>
1714+
)}
1715+
</div>
1716+
<div class="an-card">
1717+
<div class="an-card-head"><h3>Lifecycle events</h3><span class="an-card-sub">count by type</span></div>
1718+
{events.length ? (
1719+
<div class="bar-list">
1720+
{events.map(([type, count]) => (
1721+
<div class="bar-row">
1722+
<div class="bar-row-head">
1723+
<span class="bar-row-label">{x402EventLabel(type)}</span>
1724+
<span class="bar-row-value">{count}</span>
1725+
</div>
1726+
<div class="bar-track">
1727+
<div class="bar-fill blue" style={`width:${percent(count, events[0][1])}%`}></div>
1728+
</div>
1729+
</div>
1730+
))}
1731+
</div>
1732+
) : (
1733+
<div class="viz-empty">No x402 events in this range.</div>
1734+
)}
1735+
</div>
1736+
</div>
1737+
</>
1738+
);
1739+
}
1740+
15761741
function SelectedRegionCard({ region, selectedRegion, totals }: {
15771742
region: AdminRegion | null;
15781743
selectedRegion: string;

services/control-plane/src/x402-analytics.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { db } from './db';
33

44
export async function getX402Analytics(windowHours = 24, blockSeconds = 300) {
55
const safeWindowHours = Math.max(1, Math.min(720, Math.floor(windowHours)));
6-
const [totals, operations, events] = await Promise.all([
6+
const [totals, operations, events, live] = await Promise.all([
77
db.execute(sql`
88
select
99
count(*)::int as settled_payments,
@@ -31,10 +31,22 @@ export async function getX402Analytics(windowHours = 24, blockSeconds = 300) {
3131
group by event_type
3232
order by event_type
3333
`),
34+
// Live now, not window-scoped: paid access still valid and the underlying
35+
// browser session is still active (excludes expired and terminated ones).
36+
db.execute(sql`
37+
select count(*)::int as live_sessions
38+
from x402_sessions x
39+
join sessions s on s.session_id = x.session_id
40+
where x.expires_at > now()
41+
and s.status = 'active'
42+
and s.ended_at is null
43+
`),
3444
]);
3545
const row = totals[0] as Record<string, unknown> | undefined;
46+
const liveRow = live[0] as Record<string, unknown> | undefined;
3647
return {
3748
windowHours: safeWindowHours,
49+
liveSessions: Number(liveRow?.live_sessions || 0),
3850
// Revenue is deliberately sourced only from status='settled' ledger rows.
3951
revenueAtomic: String(row?.revenue_atomic || '0'),
4052
settledPayments: Number(row?.settled_payments || 0),

0 commit comments

Comments
 (0)