@actuate-media/cms-admin 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actuate-admin.css +1 -1
- package/dist/layout/Header.d.ts +14 -1
- package/dist/layout/Header.d.ts.map +1 -1
- package/dist/layout/Header.js +10 -3
- package/dist/layout/Header.js.map +1 -1
- package/dist/layout/Layout.d.ts.map +1 -1
- package/dist/layout/Layout.js +50 -2
- package/dist/layout/Layout.js.map +1 -1
- package/dist/layout/Sidebar.d.ts +7 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +121 -46
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +136 -62
- package/dist/views/Dashboard.js.map +1 -1
- package/package.json +2 -2
- package/src/layout/Header.tsx +90 -43
- package/src/layout/Layout.tsx +110 -1
- package/src/layout/Sidebar.tsx +218 -98
- package/src/views/Dashboard.tsx +258 -131
package/src/views/Dashboard.tsx
CHANGED
|
@@ -33,6 +33,10 @@ import {
|
|
|
33
33
|
Database,
|
|
34
34
|
ChevronRight,
|
|
35
35
|
Loader2,
|
|
36
|
+
TrendingUp,
|
|
37
|
+
TrendingDown,
|
|
38
|
+
Gauge,
|
|
39
|
+
AlertCircle,
|
|
36
40
|
type LucideIcon,
|
|
37
41
|
} from 'lucide-react'
|
|
38
42
|
import { useApiData } from '../lib/useApiData.js'
|
|
@@ -64,6 +68,28 @@ interface HealthData {
|
|
|
64
68
|
models: Record<string, boolean>
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
interface DeliveryStats {
|
|
72
|
+
hasData: boolean
|
|
73
|
+
requests24h: number
|
|
74
|
+
requestsPrev24h: number
|
|
75
|
+
deltaPercent: number | null
|
|
76
|
+
errorRate: number
|
|
77
|
+
errorCount: number
|
|
78
|
+
avgLatencyMs: number
|
|
79
|
+
p95LatencyMs: number
|
|
80
|
+
maxLatencyMs: number
|
|
81
|
+
webhookCount: number
|
|
82
|
+
webhookActiveCount: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ContentHealthData {
|
|
86
|
+
score: number
|
|
87
|
+
label: 'Good' | 'Fair' | 'Poor'
|
|
88
|
+
counts: Record<string, number>
|
|
89
|
+
totalIssues: number
|
|
90
|
+
lastScanAt: string | null
|
|
91
|
+
}
|
|
92
|
+
|
|
67
93
|
interface CollectionMeta {
|
|
68
94
|
slug: string
|
|
69
95
|
type?: string
|
|
@@ -114,6 +140,27 @@ function timeOfDayGreeting(): string {
|
|
|
114
140
|
return 'Good evening'
|
|
115
141
|
}
|
|
116
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Return the user's first name for the greeting. Falls back to the
|
|
145
|
+
* email local-part, then a generic "there". Strips role-style names
|
|
146
|
+
* like "Admin" / "Administrator" / "Editor" — a literal
|
|
147
|
+
* `Good morning, Admin` reads as bot-output, not a person.
|
|
148
|
+
*/
|
|
149
|
+
function firstNameForGreeting(session: any): string {
|
|
150
|
+
const raw = (session?.user?.name ?? session?.name ?? '').trim()
|
|
151
|
+
const email = session?.user?.email ?? session?.email
|
|
152
|
+
const generic = new Set(['admin', 'administrator', 'editor', 'user', 'root'])
|
|
153
|
+
if (raw) {
|
|
154
|
+
const first = raw.split(/\s+/)[0] ?? raw
|
|
155
|
+
if (!generic.has(first.toLowerCase())) return first
|
|
156
|
+
}
|
|
157
|
+
if (typeof email === 'string' && email.length > 0) {
|
|
158
|
+
const local = email.split('@')[0] ?? email
|
|
159
|
+
return local.charAt(0).toUpperCase() + local.slice(1)
|
|
160
|
+
}
|
|
161
|
+
return 'there'
|
|
162
|
+
}
|
|
163
|
+
|
|
117
164
|
function todayDateString(): string {
|
|
118
165
|
return new Date().toLocaleDateString('en-US', {
|
|
119
166
|
weekday: 'long',
|
|
@@ -200,10 +247,14 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
200
247
|
const nav = (path: string) => onNavigate?.(path)
|
|
201
248
|
const { data: stats, loading, error, exhausted, refetch } = useApiData<DashboardStats>('/stats')
|
|
202
249
|
const { data: health } = useApiData<HealthData>('/health')
|
|
250
|
+
const { data: delivery, loading: deliveryLoading } =
|
|
251
|
+
useApiData<DeliveryStats>('/stats/api-delivery')
|
|
252
|
+
const { data: contentHealthData, loading: contentHealthLoading } =
|
|
253
|
+
useApiData<ContentHealthData>('/stats/content-health')
|
|
203
254
|
|
|
204
255
|
const greeting = useMemo(() => timeOfDayGreeting(), [])
|
|
205
256
|
const dateStr = useMemo(() => todayDateString(), [])
|
|
206
|
-
const userName =
|
|
257
|
+
const userName = useMemo(() => firstNameForGreeting(session), [session])
|
|
207
258
|
|
|
208
259
|
const collections = useMemo(() => resolveCollections(config), [config])
|
|
209
260
|
|
|
@@ -301,18 +352,23 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
301
352
|
}, [stats, collections])
|
|
302
353
|
|
|
303
354
|
// ── Quick actions ───────────────────────────────────────────────────────
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
355
|
+
// 5-button strip matching the reference dashboard. The "+ New Post" entry
|
|
356
|
+
// intentionally duplicates the top-bar primary action: the top bar
|
|
357
|
+
// surface is for chrome-level navigation, the quick-action row is the
|
|
358
|
+
// dashboard's content-creation toolbar. Order mirrors the natural
|
|
359
|
+
// authoring flow (post → page → media → form → integration).
|
|
307
360
|
const quickActions = useMemo(() => {
|
|
361
|
+
const posts = collections.find((c) => c.slug === 'posts' || c.type === 'post')
|
|
362
|
+
const postsSlug = posts?.slug ?? 'posts'
|
|
308
363
|
const pages = collections.find((c) => c.slug === 'pages' || c.type === 'page')
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
364
|
+
const pagesSlug = pages?.slug ?? 'pages'
|
|
365
|
+
const items: { label: string; icon: LucideIcon; onClick: () => void }[] = [
|
|
366
|
+
{ label: 'New Post', icon: Plus, onClick: () => nav(`/${postsSlug}/new`) },
|
|
367
|
+
{ label: 'New Page', icon: Plus, onClick: () => nav(`/${pagesSlug}/new`) },
|
|
368
|
+
{ label: 'Upload Media', icon: Upload, onClick: () => nav('/media') },
|
|
369
|
+
{ label: 'New Form', icon: Plus, onClick: () => nav('/forms') },
|
|
370
|
+
{ label: 'View API', icon: Globe, onClick: () => nav('/api-keys') },
|
|
371
|
+
]
|
|
316
372
|
return items
|
|
317
373
|
}, [collections, onNavigate]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
318
374
|
|
|
@@ -351,51 +407,63 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
351
407
|
})
|
|
352
408
|
}, [stats, collections])
|
|
353
409
|
|
|
354
|
-
// ── Content health
|
|
410
|
+
// ── Content health (sourced from /stats/content-health) ─────────────────
|
|
411
|
+
// Persisted by the nightly content-health cron. The four issue rows
|
|
412
|
+
// map 1:1 to ContentIssueType in the schema; the labels are
|
|
413
|
+
// user-facing and intentionally read like the reference dashboard.
|
|
355
414
|
const contentHealth = useMemo(() => {
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
const
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (score > 0 && score < 70) {
|
|
415
|
+
const counts = contentHealthData?.counts ?? {}
|
|
416
|
+
const score = contentHealthData?.score ?? 100
|
|
417
|
+
const label = contentHealthData?.label ?? 'Good'
|
|
418
|
+
const tone: 'ok' | 'warn' | 'err' | 'muted' =
|
|
419
|
+
score >= 70 ? 'ok' : score >= 40 ? 'warn' : score > 0 ? 'err' : 'muted'
|
|
420
|
+
|
|
421
|
+
// Issue list — only render rows that have non-zero counts so the
|
|
422
|
+
// card stays compact when the corpus is healthy. The empty state
|
|
423
|
+
// below picks up when all four are zero.
|
|
424
|
+
const issues: { kind: string; label: string; count: number; tone: 'warn' | 'err' }[] = []
|
|
425
|
+
const missingMeta = counts['MISSING_META_DESCRIPTION'] ?? 0
|
|
426
|
+
const brokenLinks = counts['BROKEN_INTERNAL_LINK'] ?? 0
|
|
427
|
+
const missingAlt = counts['MISSING_ALT_TEXT'] ?? 0
|
|
428
|
+
const outdated = counts['OUTDATED_CONTENT'] ?? 0
|
|
429
|
+
if (missingMeta > 0)
|
|
372
430
|
issues.push({
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
431
|
+
kind: 'MISSING_META_DESCRIPTION',
|
|
432
|
+
label: 'Missing meta descriptions',
|
|
433
|
+
count: missingMeta,
|
|
434
|
+
tone: 'warn',
|
|
376
435
|
})
|
|
377
|
-
|
|
436
|
+
if (brokenLinks > 0)
|
|
437
|
+
issues.push({
|
|
438
|
+
kind: 'BROKEN_INTERNAL_LINK',
|
|
439
|
+
label: 'Broken internal links',
|
|
440
|
+
count: brokenLinks,
|
|
441
|
+
tone: 'err',
|
|
442
|
+
})
|
|
443
|
+
if (missingAlt > 0)
|
|
444
|
+
issues.push({
|
|
445
|
+
kind: 'MISSING_ALT_TEXT',
|
|
446
|
+
label: 'Missing alt text',
|
|
447
|
+
count: missingAlt,
|
|
448
|
+
tone: 'warn',
|
|
449
|
+
})
|
|
450
|
+
if (outdated > 0)
|
|
451
|
+
issues.push({
|
|
452
|
+
kind: 'OUTDATED_CONTENT',
|
|
453
|
+
label: 'Outdated content (90+ days)',
|
|
454
|
+
count: outdated,
|
|
455
|
+
tone: 'warn',
|
|
456
|
+
})
|
|
457
|
+
|
|
378
458
|
return {
|
|
379
459
|
score,
|
|
380
|
-
label
|
|
381
|
-
tone
|
|
460
|
+
label,
|
|
461
|
+
tone,
|
|
382
462
|
issues,
|
|
463
|
+
hasData: contentHealthData !== null,
|
|
464
|
+
loading: contentHealthLoading,
|
|
383
465
|
} as const
|
|
384
|
-
}, [
|
|
385
|
-
|
|
386
|
-
// ── Content delivery tiles ──────────────────────────────────────────────
|
|
387
|
-
const delivery = useMemo(() => {
|
|
388
|
-
const sched = stats?.statusCounts?.['SCHEDULED'] ?? 0
|
|
389
|
-
return {
|
|
390
|
-
totalDocs: stats?.totalDocuments ?? 0,
|
|
391
|
-
forms: stats?.formCount ?? 0,
|
|
392
|
-
scheduled: sched,
|
|
393
|
-
webhooks: {
|
|
394
|
-
total: stats?.webhookCount ?? 0,
|
|
395
|
-
active: stats?.webhookActiveCount ?? 0,
|
|
396
|
-
},
|
|
397
|
-
}
|
|
398
|
-
}, [stats])
|
|
466
|
+
}, [contentHealthData, contentHealthLoading])
|
|
399
467
|
|
|
400
468
|
const totalIssues = contentHealth.issues.reduce((s, i) => s + i.count, 0)
|
|
401
469
|
|
|
@@ -409,10 +477,6 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
409
477
|
)
|
|
410
478
|
}
|
|
411
479
|
|
|
412
|
-
const heroPostSlug =
|
|
413
|
-
collections.find((c) => c.slug === 'posts' || c.type === 'post')?.slug ?? 'posts'
|
|
414
|
-
const siteUrl = config?.site?.url ?? config?.seo?.siteUrl ?? null
|
|
415
|
-
|
|
416
480
|
return (
|
|
417
481
|
<div className="w-full space-y-5 p-4 sm:p-6 lg:px-8">
|
|
418
482
|
{/* Health banners ────────────────────────────────────────────── */}
|
|
@@ -452,40 +516,25 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
452
516
|
</div>
|
|
453
517
|
)}
|
|
454
518
|
|
|
455
|
-
{/*
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
<button
|
|
473
|
-
onClick={() => nav(`/${heroPostSlug}/new`)}
|
|
474
|
-
className="inline-flex items-center gap-1.5 rounded-lg bg-violet-600 px-3.5 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
|
|
475
|
-
>
|
|
476
|
-
<Plus className="h-3.5 w-3.5" /> New Post
|
|
477
|
-
</button>
|
|
478
|
-
{siteUrl && (
|
|
479
|
-
<a
|
|
480
|
-
href={siteUrl}
|
|
481
|
-
target="_blank"
|
|
482
|
-
rel="noopener noreferrer"
|
|
483
|
-
className="border-border bg-card hover:bg-accent hover:text-accent-foreground hidden items-center gap-1.5 rounded-lg border px-3 py-2 text-sm transition-colors sm:inline-flex"
|
|
484
|
-
>
|
|
485
|
-
<ExternalLink className="h-3.5 w-3.5" /> View Site
|
|
486
|
-
</a>
|
|
519
|
+
{/* Hero greeting ─────────────────────────────────────────────────
|
|
520
|
+
Primary "+ New Post" and "View Site" actions live in the top bar
|
|
521
|
+
(Header.tsx page-action slot), not here, so the hero is purely
|
|
522
|
+
informational and the dashboard's chrome-level affordances stay
|
|
523
|
+
consistent across navigation. The on-page secondary action row
|
|
524
|
+
below covers the same creation paths plus authoring shortcuts. */}
|
|
525
|
+
<div className="flex flex-col gap-1 sm:gap-0.5">
|
|
526
|
+
<h1 className="text-foreground text-xl font-semibold tracking-tight sm:text-2xl">
|
|
527
|
+
{greeting}, {userName} <span aria-hidden>👋</span>
|
|
528
|
+
</h1>
|
|
529
|
+
<p className="text-muted-foreground text-sm">
|
|
530
|
+
{dateStr}
|
|
531
|
+
{totalIssues > 0 && (
|
|
532
|
+
<span className="hidden sm:inline">
|
|
533
|
+
{' · '}
|
|
534
|
+
{totalIssues} content item{totalIssues === 1 ? '' : 's'} need attention
|
|
535
|
+
</span>
|
|
487
536
|
)}
|
|
488
|
-
</
|
|
537
|
+
</p>
|
|
489
538
|
</div>
|
|
490
539
|
|
|
491
540
|
{/* Quick actions ──────────────────────────────────────────────── */}
|
|
@@ -763,12 +812,21 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
763
812
|
</aside>
|
|
764
813
|
</div>
|
|
765
814
|
|
|
766
|
-
{/*
|
|
815
|
+
{/* Delivery API ──────────────────────────────────────────────────
|
|
816
|
+
Headless content delivery metrics aggregated over the last
|
|
817
|
+
24h from `actuate_api_request_metrics`. Populated in real-time
|
|
818
|
+
by the request logger in handler-factory.ts — no cron
|
|
819
|
+
required. Empty-state messaging differentiates "we haven't
|
|
820
|
+
collected enough data yet" from "we have data and it shows
|
|
821
|
+
0" so editors don't think the dashboard is broken on a
|
|
822
|
+
quiet weekend. */}
|
|
767
823
|
<section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
|
|
768
824
|
<header className="border-border flex items-center justify-between border-b px-4 py-3">
|
|
769
825
|
<div className="min-w-0">
|
|
770
|
-
<h2 className="text-foreground text-sm font-semibold">
|
|
771
|
-
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
826
|
+
<h2 className="text-foreground text-sm font-semibold">Delivery API</h2>
|
|
827
|
+
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
828
|
+
Headless content delivery · last 24 hours
|
|
829
|
+
</p>
|
|
772
830
|
</div>
|
|
773
831
|
<button
|
|
774
832
|
className="inline-flex items-center gap-1 text-xs font-medium text-violet-600 hover:underline dark:text-violet-400"
|
|
@@ -777,52 +835,114 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
777
835
|
API docs <ExternalLink className="h-3 w-3" />
|
|
778
836
|
</button>
|
|
779
837
|
</header>
|
|
780
|
-
<
|
|
781
|
-
<DeliveryTile
|
|
782
|
-
icon={Activity}
|
|
783
|
-
label="Total Documents"
|
|
784
|
-
value={delivery.totalDocs.toLocaleString()}
|
|
785
|
-
sub="across all collections"
|
|
786
|
-
/>
|
|
787
|
-
<DeliveryTile
|
|
788
|
-
icon={Clock}
|
|
789
|
-
label="Scheduled Posts"
|
|
790
|
-
value={delivery.scheduled.toLocaleString()}
|
|
791
|
-
sub={delivery.scheduled > 0 ? 'in publishing queue' : 'none queued'}
|
|
792
|
-
tone={delivery.scheduled > 0 ? 'ok' : 'muted'}
|
|
793
|
-
/>
|
|
794
|
-
<DeliveryTile
|
|
795
|
-
icon={ClipboardList}
|
|
796
|
-
label="Form Responses"
|
|
797
|
-
value={delivery.forms.toLocaleString()}
|
|
798
|
-
sub={delivery.forms > 0 ? 'total received' : 'none yet'}
|
|
799
|
-
tone={delivery.forms > 0 ? 'ok' : 'muted'}
|
|
800
|
-
/>
|
|
801
|
-
<DeliveryTile
|
|
802
|
-
icon={Zap}
|
|
803
|
-
label="Active Webhooks"
|
|
804
|
-
value={`${delivery.webhooks.active} / ${delivery.webhooks.total || 0}`}
|
|
805
|
-
sub={
|
|
806
|
-
delivery.webhooks.total === 0
|
|
807
|
-
? 'none configured'
|
|
808
|
-
: delivery.webhooks.active === delivery.webhooks.total
|
|
809
|
-
? 'all active'
|
|
810
|
-
: `${delivery.webhooks.total - delivery.webhooks.active} paused`
|
|
811
|
-
}
|
|
812
|
-
tone={
|
|
813
|
-
delivery.webhooks.total === 0
|
|
814
|
-
? 'muted'
|
|
815
|
-
: delivery.webhooks.active === delivery.webhooks.total
|
|
816
|
-
? 'ok'
|
|
817
|
-
: 'warn'
|
|
818
|
-
}
|
|
819
|
-
/>
|
|
820
|
-
</div>
|
|
838
|
+
<DeliveryGrid delivery={delivery} loading={deliveryLoading} />
|
|
821
839
|
</section>
|
|
822
840
|
</div>
|
|
823
841
|
)
|
|
824
842
|
}
|
|
825
843
|
|
|
844
|
+
// ─── Delivery API grid ──────────────────────────────────────────────────────
|
|
845
|
+
|
|
846
|
+
function DeliveryGrid({ delivery, loading }: { delivery: DeliveryStats | null; loading: boolean }) {
|
|
847
|
+
if (loading && !delivery) {
|
|
848
|
+
return (
|
|
849
|
+
<div
|
|
850
|
+
className="divide-border grid grid-cols-2 divide-x divide-y lg:grid-cols-4 lg:divide-y-0"
|
|
851
|
+
aria-busy
|
|
852
|
+
>
|
|
853
|
+
{[0, 1, 2, 3].map((i) => (
|
|
854
|
+
<div key={i} className="px-4 py-3.5">
|
|
855
|
+
<div className="bg-muted mb-2 h-3 w-24 rounded" />
|
|
856
|
+
<div className="bg-muted mb-1.5 h-7 w-20 rounded" />
|
|
857
|
+
<div className="bg-muted h-3 w-28 rounded" />
|
|
858
|
+
</div>
|
|
859
|
+
))}
|
|
860
|
+
</div>
|
|
861
|
+
)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const hasData = delivery?.hasData ?? false
|
|
865
|
+
const requests = delivery?.requests24h ?? 0
|
|
866
|
+
const delta: number | null = delivery?.deltaPercent ?? null
|
|
867
|
+
const errorRate = delivery?.errorRate ?? 0
|
|
868
|
+
const avgLatency = delivery?.avgLatencyMs ?? 0
|
|
869
|
+
const p95Latency = delivery?.p95LatencyMs ?? 0
|
|
870
|
+
const webhookTotal = delivery?.webhookCount ?? 0
|
|
871
|
+
const webhookActive = delivery?.webhookActiveCount ?? 0
|
|
872
|
+
|
|
873
|
+
// Pretty-print large numbers (14_823 → "14.8K") so the tile stays
|
|
874
|
+
// readable at the dashboard's compact density.
|
|
875
|
+
const requestsLabel = formatLargeNumber(requests)
|
|
876
|
+
const errorRatePct = (errorRate * 100).toFixed(2)
|
|
877
|
+
|
|
878
|
+
return (
|
|
879
|
+
<div className="divide-border grid grid-cols-2 divide-x divide-y lg:grid-cols-4 lg:divide-y-0">
|
|
880
|
+
<DeliveryTile
|
|
881
|
+
icon={Activity}
|
|
882
|
+
label="API Requests (24h)"
|
|
883
|
+
value={hasData ? requestsLabel : '—'}
|
|
884
|
+
sub={
|
|
885
|
+
!hasData
|
|
886
|
+
? 'no traffic recorded yet'
|
|
887
|
+
: delta === null
|
|
888
|
+
? 'no prior baseline'
|
|
889
|
+
: `${delta >= 0 ? '+' : ''}${delta.toFixed(1)}% vs yesterday`
|
|
890
|
+
}
|
|
891
|
+
subIcon={
|
|
892
|
+
hasData && typeof delta === 'number'
|
|
893
|
+
? delta >= 0
|
|
894
|
+
? TrendingUp
|
|
895
|
+
: TrendingDown
|
|
896
|
+
: undefined
|
|
897
|
+
}
|
|
898
|
+
tone={hasData ? (delta && delta >= 0 ? 'ok' : 'muted') : 'muted'}
|
|
899
|
+
/>
|
|
900
|
+
<DeliveryTile
|
|
901
|
+
icon={AlertCircle}
|
|
902
|
+
label="Error Rate"
|
|
903
|
+
value={hasData ? `${errorRatePct}%` : '—'}
|
|
904
|
+
sub={
|
|
905
|
+
!hasData
|
|
906
|
+
? 'no traffic recorded yet'
|
|
907
|
+
: errorRate < 0.005
|
|
908
|
+
? 'healthy'
|
|
909
|
+
: errorRate < 0.05
|
|
910
|
+
? 'elevated'
|
|
911
|
+
: 'investigate'
|
|
912
|
+
}
|
|
913
|
+
tone={!hasData ? 'muted' : errorRate < 0.005 ? 'ok' : errorRate < 0.05 ? 'warn' : 'err'}
|
|
914
|
+
/>
|
|
915
|
+
<DeliveryTile
|
|
916
|
+
icon={Gauge}
|
|
917
|
+
label="Avg Response Time"
|
|
918
|
+
value={hasData ? `${Math.round(avgLatency)}ms` : '—'}
|
|
919
|
+
sub={hasData ? `p95: ${p95Latency}ms` : 'no traffic recorded yet'}
|
|
920
|
+
tone={!hasData ? 'muted' : avgLatency < 200 ? 'ok' : avgLatency < 600 ? 'warn' : 'err'}
|
|
921
|
+
/>
|
|
922
|
+
<DeliveryTile
|
|
923
|
+
icon={Zap}
|
|
924
|
+
label="Active Webhooks"
|
|
925
|
+
value={`${webhookActive} / ${webhookTotal}`}
|
|
926
|
+
sub={
|
|
927
|
+
webhookTotal === 0
|
|
928
|
+
? 'none configured'
|
|
929
|
+
: webhookActive === webhookTotal
|
|
930
|
+
? 'All active'
|
|
931
|
+
: `${webhookTotal - webhookActive} paused`
|
|
932
|
+
}
|
|
933
|
+
tone={webhookTotal === 0 ? 'muted' : webhookActive === webhookTotal ? 'ok' : 'warn'}
|
|
934
|
+
/>
|
|
935
|
+
</div>
|
|
936
|
+
)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function formatLargeNumber(n: number): string {
|
|
940
|
+
if (n < 1000) return n.toLocaleString()
|
|
941
|
+
if (n < 10_000) return `${(n / 1000).toFixed(1)}K`
|
|
942
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(0)}K`
|
|
943
|
+
return `${(n / 1_000_000).toFixed(1)}M`
|
|
944
|
+
}
|
|
945
|
+
|
|
826
946
|
// ─── Sub-components (kept in-file: smaller bundle, no extra module hops) ────
|
|
827
947
|
|
|
828
948
|
function EmptyState({
|
|
@@ -852,12 +972,14 @@ function DeliveryTile({
|
|
|
852
972
|
label,
|
|
853
973
|
value,
|
|
854
974
|
sub,
|
|
975
|
+
subIcon: SubIcon,
|
|
855
976
|
tone = 'muted',
|
|
856
977
|
}: {
|
|
857
978
|
icon: LucideIcon
|
|
858
979
|
label: string
|
|
859
980
|
value: string
|
|
860
981
|
sub?: string
|
|
982
|
+
subIcon?: LucideIcon
|
|
861
983
|
tone?: 'ok' | 'warn' | 'err' | 'muted'
|
|
862
984
|
}) {
|
|
863
985
|
const subTone =
|
|
@@ -875,7 +997,12 @@ function DeliveryTile({
|
|
|
875
997
|
<span className="text-[11px] font-medium">{label}</span>
|
|
876
998
|
</div>
|
|
877
999
|
<p className="text-foreground text-xl leading-tight font-semibold tracking-tight">{value}</p>
|
|
878
|
-
{sub &&
|
|
1000
|
+
{sub && (
|
|
1001
|
+
<p className={`mt-0.5 inline-flex items-center gap-1 text-[11px] ${subTone}`}>
|
|
1002
|
+
{SubIcon && <SubIcon className="h-3 w-3 shrink-0" aria-hidden />}
|
|
1003
|
+
{sub}
|
|
1004
|
+
</p>
|
|
1005
|
+
)}
|
|
879
1006
|
</div>
|
|
880
1007
|
)
|
|
881
1008
|
}
|