overview-client.tsx581 lines · main
1'use client';
2
3import Link from 'next/link';
4import { useCallback, useEffect, useState } from 'react';
5
6import { ActivityIcon } from '@/components/ui/activity';
7import { CreditCardIcon } from '@/components/ui/credit-card';
8import { DatabaseIcon } from '@/components/ui/database';
9import { FoldersIcon } from '@/components/ui/folders';
10import { RocketIcon } from '@/components/ui/rocket';
11import { TriangleAlertIcon } from '@/components/ui/triangle-alert';
12import { UsersIcon } from '@/components/ui/users';
13
14import { AreaChart, type AreaChartPoint } from './_components/area-chart';
15import { EmptyState, EmptyStateButton } from './_components/empty-state';
16import { Section } from './_components/section';
17import { CountUp, StatCard } from './_components/stat-card';
18
19/* ─── payload types (mirror /v1/admin/overview) ──────────────────────────── */
20
21type HealthCheck = 'ok' | 'unreachable' | 'not_configured';
22
23interface HostMetrics {
24 cpuPercent: number | null;
25 memUsedBytes: number | null;
26 memTotalBytes: number | null;
27 diskPercent: number | null;
28 stealPercent: number | null;
29 instance?: string;
30}
31
32export interface Overview {
33 billing: {
34 subscribers: number | null;
35 mrr: number | null;
36 currency: string | null;
37 planMix: { free: number; pro: number; team: number } | null;
38 churn30d: number | null;
39 };
40 health: {
41 checks: {
42 control_postgres: HealthCheck;
43 data_plane_postgres: HealthCheck;
44 runtime: HealthCheck;
45 redis: HealthCheck;
46 };
47 host: HostMetrics | null;
48 };
49 openIncidents: number;
50 recentDeploys: Array<{
51 id: string;
52 service: string;
53 buildSha: string;
54 buildAt: string | null;
55 env: string;
56 bootedAt: string;
57 }>;
58 counts: { projects: number; users: number };
59}
60
61/* ─── payload types (mirror /v1/admin/timeseries) ────────────────────────── */
62
63interface DailyPoint {
64 day: string; // 'YYYY-MM-DD'
65 count: number;
66}
67
68interface Timeseries {
69 signupsDaily: DailyPoint[];
70 deploysDaily: DailyPoint[];
71 invocationsDaily: DailyPoint[];
72 apiRequests: Array<{ t: string; perMin: number }> | null;
73 hostCpu: Array<{ t: string; pct: number }> | null;
74}
75
76/* ─── live-polled dashboard ──────────────────────────────────────────────── */
77
78const POLL_MS = 10_000;
79/** ~20 min of 10s samples for the live cpu chart. */
80const MAX_SAMPLES = 120;
81/**
82 * Daily/24h series barely move — refetching every 10s alongside the
83 * overview poll would be pure waste. Once on mount + every 5 min.
84 */
85const SERIES_POLL_MS = 5 * 60_000;
86
87export function OverviewDashboard({
88 apiOrigin,
89 initial,
90}: {
91 apiOrigin: string;
92 initial: Overview | null;
93}) {
94 const [data, setData] = useState<Overview | null>(initial);
95 const [failed, setFailed] = useState(false);
96 const [updatedAt, setUpdatedAt] = useState<number | null>(initial ? Date.now() : null);
97 // Real cpu% samples accumulated from the live poll — the chart never
98 // shows fabricated history, it grows as genuine samples arrive.
99 const [cpuSamples, setCpuSamples] = useState<AreaChartPoint[]>([]);
100
101 const load = useCallback(async () => {
102 try {
103 const res = await fetch(`${apiOrigin}/v1/admin/overview`, {
104 credentials: 'include',
105 headers: { accept: 'application/json' },
106 });
107 if (!res.ok) throw new Error(`overview failed: ${res.status}`);
108 const json = (await res.json()) as Overview;
109 setData(json);
110 setUpdatedAt(Date.now());
111 setFailed(false);
112 const cpu = json.health.host?.cpuPercent;
113 if (typeof cpu === 'number') {
114 setCpuSamples((prev) => [...prev.slice(-(MAX_SAMPLES - 1)), { x: Date.now(), y: cpu }]);
115 }
116 } catch {
117 setFailed(true);
118 }
119 }, [apiOrigin]);
120
121 useEffect(() => {
122 void load();
123 const timer = setInterval(() => {
124 if (document.visibilityState === 'visible') void load();
125 }, POLL_MS);
126 return () => clearInterval(timer);
127 }, [load]);
128
129 // 30-day + 24h activity series — real history from /v1/admin/timeseries.
130 const [series, setSeries] = useState<Timeseries | null>(null);
131
132 const loadSeries = useCallback(async () => {
133 try {
134 const res = await fetch(`${apiOrigin}/v1/admin/timeseries`, {
135 credentials: 'include',
136 headers: { accept: 'application/json' },
137 });
138 if (!res.ok) throw new Error(`timeseries failed: ${res.status}`);
139 setSeries((await res.json()) as Timeseries);
140 } catch {
141 // Keep the last good series — a missed 5-min refresh on daily data
142 // is invisible; the main overview poll already surfaces staleness.
143 }
144 }, [apiOrigin]);
145
146 useEffect(() => {
147 void loadSeries();
148 const timer = setInterval(() => {
149 if (document.visibilityState === 'visible') void loadSeries();
150 }, SERIES_POLL_MS);
151 return () => clearInterval(timer);
152 }, [loadSeries]);
153
154 if (data === null) {
155 if (failed) {
156 return (
157 <EmptyState
158 icon={<TriangleAlertIcon size={28} />}
159 title="overview unavailable"
160 message="the api didn't answer — it may be restarting or your session may have expired. retrying keeps your place; nothing is lost."
161 action={<EmptyStateButton onClick={() => void load()}>retry now</EmptyStateButton>}
162 />
163 );
164 }
165 return (
166 <EmptyState
167 icon={<ActivityIcon size={28} />}
168 title="loading overview…"
169 message="fetching live platform data."
170 />
171 );
172 }
173
174 const { billing, health, openIncidents, recentDeploys, counts } = data;
175
176 return (
177 <div className="flex flex-col gap-10">
178 <header className="flex flex-wrap items-end justify-between gap-4">
179 <div className="flex flex-col gap-2">
180 <div className="flex items-center gap-2">
181 <span className="text-[var(--color-primary)]">
182 <ActivityIcon size={20} />
183 </span>
184 <h1 className="font-mono text-xl tracking-tight">overview</h1>
185 </div>
186 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
187 the platform at a glance — money in, engine alive. real numbers only; anything we
188 can&apos;t yet prove shows &ldquo;—&rdquo; with what it&apos;s waiting on.
189 </p>
190 </div>
191 <LiveBadge updatedAt={updatedAt} stale={failed} />
192 </header>
193
194 {/* ── top row: the four numbers that matter ─────────────────────── */}
195 <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4">
196 <StatCard
197 label="users"
198 value={counts.users}
199 icon={<UsersIcon size={14} />}
200 hint="non-deleted accounts"
201 />
202 <StatCard
203 label="projects"
204 value={counts.projects}
205 icon={<FoldersIcon size={14} />}
206 hint="non-deleted totals"
207 />
208 <StatCard
209 label="active incidents"
210 value={openIncidents}
211 icon={<TriangleAlertIcon size={14} />}
212 tone={openIncidents > 0 ? 'warning' : 'default'}
213 hint="unresolved · live"
214 />
215 <StatCard
216 label="mrr"
217 value={billing.mrr}
218 prefix={currencySymbol(billing.currency)}
219 icon={<CreditCardIcon size={14} />}
220 tone="primary"
221 hint="monthly recurring revenue"
222 waitingOn="Mavi Pay not configured"
223 />
224 </div>
225
226 {/* ── live host cpu chart ───────────────────────────────────────── */}
227 <Section
228 title={
229 health.host?.instance ? `host cpu · live · ${health.host.instance}` : 'host cpu · live'
230 }
231 icon={<ActivityIcon size={16} />}
232 right={
233 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
234 sampled every 10s this session
235 </span>
236 }
237 >
238 {health.host === null ? (
239 <EmptyState
240 icon={<TriangleAlertIcon size={24} />}
241 title="monitoring not connected"
242 message="host cpu appears here once Prometheus is wired up — no fake demo curve in the meantime."
243 />
244 ) : (
245 <div className="rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
246 <AreaChart
247 data={cpuSamples}
248 height={200}
249 yFormat={(y) => `${y.toFixed(0)}%`}
250 xFormat={(x) =>
251 new Date(x).toLocaleTimeString(undefined, {
252 hour: '2-digit',
253 minute: '2-digit',
254 second: '2-digit',
255 })
256 }
257 ariaLabel="host cpu percent over the current session"
258 />
259 </div>
260 )}
261 </Section>
262
263 {/* ── activity charts (30d dailies + 24h api traffic) ───────────── */}
264 <Section
265 title="activity"
266 icon={<ActivityIcon size={16} />}
267 right={
268 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
269 real history · refreshes every 5 min
270 </span>
271 }
272 >
273 <div className="flex flex-col gap-6">
274 <div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
275 <DailyChartCard
276 label="signups · 30d"
277 points={series?.signupsDaily ?? null}
278 ariaLabel="new user signups per day, last 30 days"
279 />
280 <DailyChartCard
281 label="deploys · 30d"
282 points={series?.deploysDaily ?? null}
283 ariaLabel="platform deploys per day, last 30 days"
284 />
285 <DailyChartCard
286 label="invocations · 30d"
287 points={series?.invocationsDaily ?? null}
288 ariaLabel="function invocations per day, last 30 days"
289 />
290 </div>
291
292 {series !== null && series.apiRequests === null && series.hostCpu === null ? (
293 <EmptyState
294 icon={<TriangleAlertIcon size={24} />}
295 title="monitoring not connected"
296 message="api traffic and 24h host cpu appear here once Prometheus answers — start the observability stack. no fake demo curve in the meantime."
297 />
298 ) : null}
299 {series?.apiRequests ? (
300 <WideRangeChartCard
301 label="api traffic · 24h"
302 data={series.apiRequests.map((p) => ({ x: Date.parse(p.t), y: p.perMin }))}
303 yFormat={(y) => `${y.toLocaleString('en-US', { maximumFractionDigits: 1 })}/min`}
304 ariaLabel="api requests per minute over the last 24 hours"
305 />
306 ) : null}
307 {series?.hostCpu ? (
308 <WideRangeChartCard
309 label="host cpu · 24h"
310 data={series.hostCpu.map((p) => ({ x: Date.parse(p.t), y: p.pct }))}
311 yFormat={(y) => `${y.toFixed(0)}%`}
312 ariaLabel="host cpu percent over the last 24 hours"
313 />
314 ) : null}
315 </div>
316 </Section>
317
318 {/* ── platform health tiles ─────────────────────────────────────── */}
319 <Section
320 title="platform health"
321 icon={<DatabaseIcon size={16} />}
322 right={
323 <Link
324 href="/admin/health"
325 className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)] transition hover:text-[var(--color-text-link)]"
326 >
327 open health →
328 </Link>
329 }
330 >
331 <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4">
332 {(
333 [
334 ['control_postgres', 'control db'],
335 ['data_plane_postgres', 'data plane'],
336 ['runtime', 'runtime'],
337 ['redis', 'redis'],
338 ] as const
339 ).map(([key, label]) => (
340 <HealthTile key={key} label={label} state={health.checks[key]} />
341 ))}
342 </div>
343 </Section>
344
345 {/* ── business row ──────────────────────────────────────────────── */}
346 <Section title="business · Mavi Pay" icon={<CreditCardIcon size={16} />}>
347 <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
348 <StatCard
349 label="paid subscribers"
350 value={billing.subscribers}
351 tone="primary"
352 hint="non-deleted projects on pro + team"
353 waitingOn="Mavi Pay not configured"
354 />
355 <PlanMixCard planMix={billing.planMix} />
356 <StatCard
357 label="churn · 30d"
358 value={billing.churn30d}
359 hint="subscriptions canceled · last 30d"
360 waitingOn="Mavi Pay not configured"
361 />
362 </div>
363 </Section>
364
365 {/* ── recent deploys ────────────────────────────────────────────── */}
366 <Section
367 title="recent deploys · last 3"
368 icon={<RocketIcon size={16} />}
369 right={
370 <Link
371 href="/admin/deploys"
372 className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)] transition hover:text-[var(--color-text-link)]"
373 >
374 all deploys →
375 </Link>
376 }
377 >
378 {recentDeploys.length === 0 ? (
379 <EmptyState
380 icon={<RocketIcon size={24} />}
381 title="no deploys recorded yet"
382 message="the first api or web boot with build metadata will show up here."
383 />
384 ) : (
385 <ul className="flex flex-col divide-y divide-[var(--color-border-subtle)] rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
386 {recentDeploys.map((d) => (
387 <li
388 key={d.id}
389 className="flex items-center justify-between gap-4 px-6 py-4 font-mono text-xs"
390 >
391 <span className="flex items-center gap-3">
392 <span className="text-[var(--color-primary)]">{d.service}</span>
393 <span className="text-[var(--color-text-muted)]">{d.buildSha.slice(0, 7)}</span>
394 <span className="rounded-full border border-[var(--color-border-subtle)] px-2 py-0.5 text-[9px] uppercase tracking-wider text-[var(--color-text-subtle)]">
395 {d.env}
396 </span>
397 </span>
398 <time className="shrink-0 text-[var(--color-text-subtle)]" dateTime={d.bootedAt}>
399 {new Date(d.bootedAt).toLocaleString()}
400 </time>
401 </li>
402 ))}
403 </ul>
404 )}
405 </Section>
406 </div>
407 );
408}
409
410/* ─── small pieces ───────────────────────────────────────────────────────── */
411
412/** 'YYYY-MM-DD' midnight-UTC ms → 'jun 4' style lowercase label. */
413function formatDay(x: number): string {
414 return new Date(x)
415 .toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
416 .toLowerCase();
417}
418
419/** Sample ms → 'hh:mm' label for the 24h range charts. */
420function formatClock(x: number): string {
421 return new Date(x).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
422}
423
424/**
425 * One 30-day daily series in a surface card. `points === null` means the
426 * first fetch hasn't landed yet — the AreaChart's own pending frame keeps
427 * the grid stable without inventing data.
428 */
429function DailyChartCard({
430 label,
431 points,
432 ariaLabel,
433}: {
434 label: string;
435 points: DailyPoint[] | null;
436 ariaLabel: string;
437}) {
438 const data: AreaChartPoint[] = (points ?? []).map((p) => ({
439 x: Date.parse(`${p.day}T00:00:00Z`),
440 y: p.count,
441 }));
442 return (
443 <div className="flex h-full flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
444 <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
445 {label}
446 </p>
447 <AreaChart
448 data={data}
449 height={140}
450 yFormat={(y) => y.toLocaleString('en-US', { maximumFractionDigits: 0 })}
451 xFormat={formatDay}
452 ariaLabel={ariaLabel}
453 pendingLabel={points === null ? 'loading 30-day history…' : 'no activity recorded yet.'}
454 />
455 </div>
456 );
457}
458
459/** Full-width 24h range chart (prometheus-backed, only rendered when real). */
460function WideRangeChartCard({
461 label,
462 data,
463 yFormat,
464 ariaLabel,
465}: {
466 label: string;
467 data: AreaChartPoint[];
468 yFormat: (y: number) => string;
469 ariaLabel: string;
470}) {
471 return (
472 <div className="flex flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
473 <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
474 {label}
475 </p>
476 <AreaChart
477 data={data}
478 height={200}
479 yFormat={yFormat}
480 xFormat={formatClock}
481 ariaLabel={ariaLabel}
482 />
483 </div>
484 );
485}
486
487/** ISO currency code → display symbol, falling back to the code itself. */
488function currencySymbol(code: string | null): string {
489 switch (code) {
490 case 'EUR':
491 return '€';
492 case 'USD':
493 return '$';
494 case 'GBP':
495 return '£';
496 default:
497 return code ? `${code} ` : '';
498 }
499}
500
501function LiveBadge({ updatedAt, stale }: { updatedAt: number | null; stale: boolean }) {
502 return (
503 <span className="flex items-center gap-2 rounded-full border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-3 py-1.5 font-mono text-[10px] text-[var(--color-text-subtle)]">
504 <StatusDot state={stale ? 'unreachable' : 'ok'} />
505 {stale
506 ? 'stale — retrying'
507 : updatedAt
508 ? `live · updated ${new Date(updatedAt).toLocaleTimeString()}`
509 : 'live'}
510 </span>
511 );
512}
513
514/** Pulsing status dot — green pulse ok, red pulse unreachable, amber static. */
515function StatusDot({ state }: { state: HealthCheck }) {
516 const color =
517 state === 'ok'
518 ? 'bg-[var(--color-success)]'
519 : state === 'unreachable'
520 ? 'bg-[var(--color-error)]'
521 : 'bg-[var(--color-warning)]';
522 return (
523 <span className="relative flex h-2.5 w-2.5 shrink-0" aria-hidden>
524 {state !== 'not_configured' ? (
525 <span
526 className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${color}`}
527 />
528 ) : null}
529 <span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${color}`} />
530 </span>
531 );
532}
533
534function HealthTile({ label, state }: { label: string; state: HealthCheck }) {
535 return (
536 <div className="flex h-full flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
537 <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
538 {label}
539 </p>
540 <div className="flex items-center gap-3">
541 <StatusDot state={state} />
542 <span className="font-mono text-sm text-[var(--color-text)]">
543 {state === 'not_configured' ? 'not configured' : state}
544 </span>
545 </div>
546 </div>
547 );
548}
549
550function PlanMixCard({
551 planMix,
552}: {
553 planMix: { free: number; pro: number; team: number } | null;
554}) {
555 return (
556 <div className="flex h-full flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
557 <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
558 plan mix
559 </p>
560 {planMix === null ? (
561 <div className="flex flex-col gap-1.5">
562 <p className="font-mono text-4xl tracking-tight text-[var(--color-text-subtle)]">—</p>
563 <p className="font-mono text-[11px] text-[var(--color-text-subtle)]">
564 Mavi Pay not configured
565 </p>
566 </div>
567 ) : (
568 <dl className="flex flex-col gap-2 font-mono text-sm">
569 {(['free', 'pro', 'team'] as const).map((tier) => (
570 <div key={tier} className="flex items-center justify-between">
571 <dt className="text-[var(--color-text-muted)]">{tier}</dt>
572 <dd className="text-[var(--color-text)]">
573 <CountUp value={planMix[tier]} />
574 </dd>
575 </div>
576 ))}
577 </dl>
578 )}
579 </div>
580 );
581}