page.tsx169 lines · main
1import { apiJson } from '@/lib/api';
2import { toValidDate } from '@/lib/utils';
3import { RetrySkippedForm } from './retry-skipped-form';
4
5interface UsageEvent {
6 id: string;
7 projectId: string;
8 metric: 'invocations' | 'storage_bytes' | 'connection_seconds';
9 periodStart: string | Date;
10 value: string;
11 polarPushStatus: 'pending' | 'pushed' | 'skipped';
12 polarPushedAt: string | Date | null;
13 createdAt: string | Date;
14}
15
16export const dynamic = 'force-dynamic';
17export const metadata = { title: 'admin · usage' };
18
19const STATUS_FILTERS = [
20 { label: 'all', value: '' },
21 { label: 'pending', value: 'pending' },
22 { label: 'pushed', value: 'pushed' },
23 { label: 'skipped', value: 'skipped' },
24] as const;
25
26function formatTs(t: string | Date | null): string {
27 const d = toValidDate(t);
28 return d ? d.toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '—';
29}
30
31function formatValue(metric: UsageEvent['metric'], value: string): string {
32 const n = Number(value);
33 if (!Number.isFinite(n)) return value;
34 if (metric === 'storage_bytes') {
35 if (n < 1024) return `${n} B`;
36 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
37 if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
38 return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
39 }
40 if (metric === 'connection_seconds') {
41 if (n < 60) return `${n}s`;
42 if (n < 3600) return `${(n / 60).toFixed(1)}m`;
43 return `${(n / 3600).toFixed(1)}h`;
44 }
45 // invocations
46 return n.toLocaleString();
47}
48
49function statusClass(s: UsageEvent['polarPushStatus']): string {
50 if (s === 'pushed')
51 return 'inline-flex rounded-md bg-[var(--color-primary-subtle)] px-2 py-0.5 text-[var(--color-primary)]';
52 if (s === 'pending')
53 return 'inline-flex rounded-md bg-yellow-500/10 px-2 py-0.5 text-yellow-300';
54 return 'inline-flex rounded-md bg-[var(--color-surface)] px-2 py-0.5 text-[var(--color-text-subtle)]';
55}
56
57function publicApiOrigin(): string {
58 return process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? '';
59}
60
61export default async function AdminUsagePage({
62 searchParams,
63}: {
64 searchParams: Promise<{ status?: string }>;
65}) {
66 const { status } = await searchParams;
67 const qs = status && status !== '' ? `?status=${encodeURIComponent(status)}` : '';
68 const { events } = await apiJson<{ events: UsageEvent[] }>(`/v1/admin/usage-events${qs}`).catch(
69 () => ({ events: [] as UsageEvent[] }),
70 );
71 const apiOrigin = publicApiOrigin();
72
73 const counts = events.reduce<Record<string, number>>((acc, e) => {
74 acc[e.polarPushStatus] = (acc[e.polarPushStatus] ?? 0) + 1;
75 return acc;
76 }, {});
77
78 return (
79 <div className="flex flex-col gap-6">
80 <div>
81 <h2 className="font-mono text-lg">usage events</h2>
82 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
83 one row per (project × hour × metric), populated by the hourly aggregation cron.
84 status='pending' means the polar push worker hasn&apos;t shipped it yet (or polar
85 meter ids aren&apos;t configured). status='pushed' means polar accepted the meter
86 event.
87 </p>
88 </div>
89
90 <div className="flex flex-wrap items-center justify-between gap-3">
91 <nav className="flex flex-wrap items-center gap-2 font-mono text-xs">
92 {STATUS_FILTERS.map((f) => {
93 const active = (status ?? '') === f.value;
94 const count = f.value === '' ? events.length : (counts[f.value] ?? 0);
95 return (
96 <a
97 key={f.value}
98 href={`/admin/usage${f.value ? `?status=${f.value}` : ''}`}
99 className={`rounded-md border px-3 py-1 ${
100 active
101 ? 'border-[var(--color-primary)] bg-[var(--color-surface-raised)] text-[var(--color-text)]'
102 : 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
103 }`}
104 >
105 {f.label} · {count}
106 </a>
107 );
108 })}
109 </nav>
110
111 {/* Retry-skipped form — only renders when there are skipped rows to do
112 something about. Window in days so the same control covers both
113 "just fixed the meter id" and reconciliation sweeps. Step-up
114 gated; the prompt fires inline on stale auth. */}
115 {(counts.skipped ?? 0) > 0 ? (
116 <RetrySkippedForm apiOrigin={apiOrigin} />
117 ) : null}
118 </div>
119
120 {events.length === 0 ? (
121 <div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-6 font-mono text-sm text-[var(--color-text-muted)]">
122 no usage events yet. the hourly aggregator runs ~5 minutes after every wall-clock
123 hour boundary — first row appears within an hour of api boot. if it&apos;s been
124 longer, check the api logs for <code>usage_rollup_done</code>.
125 </div>
126 ) : (
127 <div className="overflow-x-auto rounded-md border border-[var(--color-border-subtle)]">
128 <table className="w-full font-mono text-xs">
129 <thead className="bg-[var(--color-surface)] text-left text-[var(--color-text-muted)]">
130 <tr>
131 <th className="px-3 py-2 font-medium">period</th>
132 <th className="px-3 py-2 font-medium">project</th>
133 <th className="px-3 py-2 font-medium">metric</th>
134 <th className="px-3 py-2 font-medium">value</th>
135 <th className="px-3 py-2 font-medium">polar</th>
136 <th className="px-3 py-2 font-medium">pushed at</th>
137 </tr>
138 </thead>
139 <tbody>
140 {events.map((e) => (
141 <tr
142 key={e.id}
143 className="border-t border-[var(--color-border-subtle)] align-top"
144 >
145 <td className="whitespace-nowrap px-3 py-2 text-[var(--color-text-subtle)]">
146 {formatTs(e.periodStart)}
147 </td>
148 <td className="px-3 py-2 text-[var(--color-text-muted)]">{e.projectId}</td>
149 <td className="px-3 py-2 text-[var(--color-text)]">{e.metric}</td>
150 <td className="px-3 py-2 text-[var(--color-text)]">
151 {formatValue(e.metric, e.value)}
152 </td>
153 <td className="px-3 py-2">
154 <span className={statusClass(e.polarPushStatus)}>
155 {e.polarPushStatus}
156 </span>
157 </td>
158 <td className="px-3 py-2 text-[var(--color-text-subtle)]">
159 {formatTs(e.polarPushedAt)}
160 </td>
161 </tr>
162 ))}
163 </tbody>
164 </table>
165 </div>
166 )}
167 </div>
168 );
169}