@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.
@@ -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 = session?.name ?? session?.email?.split('@')[0] ?? 'there'
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
- // "New Post" lives only in the hero CTA above; this row is the secondary
305
- // surface and intentionally omits the primary action to avoid the obvious
306
- // duplication. Order mirrors the natural authoring flow.
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 items: { label: string; icon: LucideIcon; onClick: () => void }[] = []
310
- if (pages)
311
- items.push({ label: 'New Page', icon: Plus, onClick: () => nav(`/${pages.slug}/new`) })
312
- items.push({ label: 'Upload Media', icon: Upload, onClick: () => nav('/media') })
313
- items.push({ label: 'New Form', icon: Plus, onClick: () => nav('/forms') })
314
- items.push({ label: 'Manage SEO', icon: Search, onClick: () => nav('/seo') })
315
- items.push({ label: 'View API', icon: Globe, onClick: () => nav('/api-keys') })
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 summary (derived from /stats) ────────────────────────
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 score = stats?.avgSeoScore ?? 0
357
- const counts = stats?.statusCounts ?? {}
358
- const totalDrafts = counts['DRAFT'] ?? 0
359
- const totalScheduled = counts['SCHEDULED'] ?? 0
360
- const totalInReview = counts['IN_REVIEW'] ?? 0
361
- const issues: { label: string; count: number; tone: 'warn' | 'err' | 'muted' }[] = []
362
- if (totalDrafts > 0) {
363
- issues.push({ label: 'Drafts pending publish', count: totalDrafts, tone: 'warn' })
364
- }
365
- if (totalInReview > 0) {
366
- issues.push({ label: 'Awaiting review', count: totalInReview, tone: 'warn' })
367
- }
368
- if (totalScheduled > 0) {
369
- issues.push({ label: 'Scheduled to publish', count: totalScheduled, tone: 'muted' })
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
- label: 'Pages with weak SEO',
374
- count: Math.max(1, Math.round((70 - score) / 10)),
375
- tone: score < 40 ? 'err' : 'warn',
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: score >= 70 ? 'Good' : score >= 40 ? 'Fair' : score > 0 ? 'Poor' : 'No data',
381
- tone: score >= 70 ? 'ok' : score >= 40 ? 'warn' : score > 0 ? 'err' : 'muted',
460
+ label,
461
+ tone,
382
462
  issues,
463
+ hasData: contentHealthData !== null,
464
+ loading: contentHealthLoading,
383
465
  } as const
384
- }, [stats])
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
- {/* Header ─────────────────────────────────────────────────────── */}
456
- <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
457
- <div className="min-w-0">
458
- <h1 className="text-foreground text-xl font-semibold tracking-tight sm:text-2xl">
459
- {greeting}, {userName} <span aria-hidden>👋</span>
460
- </h1>
461
- <p className="text-muted-foreground mt-0.5 text-sm">
462
- {dateStr}
463
- {totalIssues > 0 && (
464
- <span className="hidden sm:inline">
465
- {' · '}
466
- {totalIssues} content item{totalIssues === 1 ? '' : 's'} need attention
467
- </span>
468
- )}
469
- </p>
470
- </div>
471
- <div className="flex shrink-0 items-center gap-2">
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
- </div>
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
- {/* Content Delivery ──────────────────────────────────────────── */}
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">Content Delivery</h2>
771
- <p className="text-muted-foreground mt-0.5 text-xs">Real-time platform activity</p>
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
- <div className="divide-border grid grid-cols-2 divide-x divide-y lg:grid-cols-4 lg:divide-y-0">
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 && <p className={`mt-0.5 text-[11px] ${subTone}`}>{sub}</p>}
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
  }