@littlebearapps/platform-admin-sdk 2.0.0 → 2.1.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.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +86 -2
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
  6. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  7. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  8. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  9. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  10. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  11. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  12. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  13. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  14. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  15. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  16. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  17. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  18. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  19. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  20. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  21. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  22. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  23. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  24. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  25. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  26. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  27. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  28. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  29. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  30. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  31. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  32. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  33. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  34. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  35. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  36. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  37. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  38. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  39. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  40. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  41. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  42. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  43. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  44. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  45. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  46. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  47. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  48. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  49. package/templates/shared/tests/unit/billing.test.ts +331 -0
  50. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  51. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  52. package/templates/shared/tests/unit/control.test.ts +226 -0
  53. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  54. package/templates/shared/tests/unit/economics.test.ts +365 -0
  55. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  56. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  57. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  58. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  59. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  60. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  61. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  62. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  63. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  64. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  65. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  66. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  67. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  68. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  69. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  70. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  71. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  72. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  73. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  74. package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
@@ -0,0 +1,757 @@
1
+ /**
2
+ * HeroCardsRow Component
3
+ *
4
+ * Row of hero cards showing key metrics:
5
+ * - MTD Spend with burn rate
6
+ * - Plan Utilisation (highest service)
7
+ * - Top Spender (by resource type)
8
+ *
9
+ * React adaptation of Astro hero cards for the unified dashboard.
10
+ */
11
+
12
+ import { useMemo } from 'react';
13
+ import {
14
+ TrendingUp,
15
+ TrendingDown,
16
+ Minus,
17
+ Activity,
18
+ BarChart3,
19
+ Zap,
20
+ Shield,
21
+ Gauge,
22
+ } from 'lucide-react';
23
+ import { clsx } from 'clsx';
24
+ import type {
25
+ BurnRateData,
26
+ ServiceUtilisation,
27
+ ProjectTableRow,
28
+ BillingContextResponse,
29
+ ToolType,
30
+ } from './types';
31
+
32
+ interface HeroCardsRowProps {
33
+ burnRate: BurnRateData | null;
34
+ services: ServiceUtilisation[];
35
+ projects?: ProjectTableRow[];
36
+ billingContext?: BillingContextResponse | null;
37
+ usageTotals?: Record<ToolType, number>; // Current usage totals by tool
38
+ onMTDSpendClick?: () => void;
39
+ onPlanUtilisationClick?: (serviceId: string) => void;
40
+ onPlanHealthClick?: () => void;
41
+ onTopSpenderClick?: () => void;
42
+ onSystemHealthClick?: (filter: 'critical' | 'warning' | 'all') => void;
43
+ }
44
+
45
+ const SERVICE_LABELS: Record<string, string> = {
46
+ workers: 'Workers',
47
+ d1: 'D1 Database',
48
+ kv: 'KV Storage',
49
+ r2: 'R2 Storage',
50
+ durableObjects: 'Durable Objects',
51
+ vectorize: 'Vectorize',
52
+ aiGateway: 'AI Gateway',
53
+ workersAI: 'Workers AI',
54
+ queues: 'Queues',
55
+ };
56
+
57
+ const SERVICE_COLORS: Record<string, string> = {
58
+ workers: 'bg-blue-500',
59
+ d1: 'bg-emerald-500',
60
+ kv: 'bg-amber-500',
61
+ r2: 'bg-violet-500',
62
+ durableObjects: 'bg-pink-500',
63
+ vectorize: 'bg-cyan-500',
64
+ aiGateway: 'bg-indigo-500',
65
+ workersAI: 'bg-red-500',
66
+ queues: 'bg-teal-500',
67
+ 'cf-workers': 'bg-blue-500',
68
+ 'cf-d1': 'bg-emerald-500',
69
+ 'cf-kv': 'bg-amber-500',
70
+ 'cf-r2': 'bg-violet-500',
71
+ 'cf-do': 'bg-pink-500',
72
+ 'cf-vectorize': 'bg-cyan-500',
73
+ 'cf-ai-gateway': 'bg-indigo-500',
74
+ 'cf-workers-ai': 'bg-red-500',
75
+ 'cf-queues': 'bg-teal-500',
76
+ };
77
+
78
+ function formatCost(cost: number): string {
79
+ if (cost >= 1000) return `$${(cost / 1000).toFixed(1)}K`;
80
+ if (cost >= 1) return `$${cost.toFixed(2)}`;
81
+ if (cost >= 0.01) return `$${cost.toFixed(3)}`;
82
+ return `$${cost.toFixed(4)}`;
83
+ }
84
+
85
+ function formatNumber(num: number): string {
86
+ if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
87
+ if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
88
+ if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
89
+ return num.toFixed(0);
90
+ }
91
+
92
+ /**
93
+ * Status thresholds for BulletChart
94
+ */
95
+ function getStatusFromPercentage(pct: number): 'green' | 'yellow' | 'red' {
96
+ if (pct >= 90) return 'red';
97
+ if (pct >= 75) return 'yellow';
98
+ return 'green';
99
+ }
100
+
101
+ const STATUS_BAR_COLORS: Record<'green' | 'yellow' | 'red', string> = {
102
+ green: 'bg-emerald-500',
103
+ yellow: 'bg-amber-500',
104
+ red: 'bg-rose-500',
105
+ };
106
+
107
+ /**
108
+ * BulletChart Component - Compact horizontal progress bar with status colours
109
+ */
110
+ function BulletChart({ label, value, max }: { label: string; value: number; max: number }) {
111
+ const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
112
+ const status = getStatusFromPercentage(pct);
113
+ const displayPct = pct > 999 ? '>999' : pct.toFixed(0);
114
+
115
+ return (
116
+ <div className="flex items-center gap-2 text-xs">
117
+ <span className="w-16 truncate text-gray-600 dark:text-slate-400" title={label}>
118
+ {label}
119
+ </span>
120
+ <div className="flex-1 h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
121
+ <div
122
+ className={clsx('h-full rounded-full transition-all', STATUS_BAR_COLORS[status])}
123
+ style={{ width: `${Math.min(pct, 100)}%` }}
124
+ />
125
+ </div>
126
+ <span
127
+ className={clsx(
128
+ 'w-10 text-right font-mono font-semibold',
129
+ status === 'red' && 'text-rose-400',
130
+ status === 'yellow' && 'text-amber-400',
131
+ status === 'green' && 'text-emerald-400'
132
+ )}
133
+ >
134
+ {displayPct}%
135
+ </span>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Plan Health Card - Shows consumption vs Cloudflare Paid Plan allowances
142
+ */
143
+ function PlanHealthCard({
144
+ billingContext,
145
+ usageTotals,
146
+ onClick,
147
+ }: {
148
+ billingContext: BillingContextResponse | null;
149
+ usageTotals?: Record<ToolType, number>;
150
+ onClick?: () => void;
151
+ }) {
152
+ // Key services to display (Workers, D1, KV, Durable Objects)
153
+ const services = useMemo(() => {
154
+ if (!billingContext || !usageTotals) return [];
155
+
156
+ const allowances = billingContext.allowances;
157
+ const items: Array<{
158
+ key: string;
159
+ label: string;
160
+ value: number;
161
+ max: number;
162
+ unit: string;
163
+ pct: number;
164
+ }> = [];
165
+
166
+ // Workers requests
167
+ if (allowances.workers) {
168
+ const value = usageTotals.workers ?? 0;
169
+ const max = allowances.workers.prorated;
170
+ items.push({
171
+ key: 'workers',
172
+ label: 'Workers',
173
+ value,
174
+ max,
175
+ unit: allowances.workers.unit,
176
+ pct: max > 0 ? (value / max) * 100 : 0,
177
+ });
178
+ }
179
+
180
+ // D1 (tracks writes as primary metric)
181
+ if (allowances.d1) {
182
+ const value = usageTotals.d1 ?? 0;
183
+ const max = allowances.d1.prorated;
184
+ items.push({
185
+ key: 'd1',
186
+ label: 'D1',
187
+ value,
188
+ max,
189
+ unit: allowances.d1.unit,
190
+ pct: max > 0 ? (value / max) * 100 : 0,
191
+ });
192
+ }
193
+
194
+ // KV (tracks writes as primary metric)
195
+ if (allowances.kv) {
196
+ const value = usageTotals.kv ?? 0;
197
+ const max = allowances.kv.prorated;
198
+ items.push({
199
+ key: 'kv',
200
+ label: 'KV',
201
+ value,
202
+ max,
203
+ unit: allowances.kv.unit,
204
+ pct: max > 0 ? (value / max) * 100 : 0,
205
+ });
206
+ }
207
+
208
+ // Durable Objects
209
+ if (allowances.durableObjects) {
210
+ const value = usageTotals.durableObjects ?? 0;
211
+ const max = allowances.durableObjects.prorated;
212
+ items.push({
213
+ key: 'durableObjects',
214
+ label: 'DO',
215
+ value,
216
+ max,
217
+ unit: allowances.durableObjects.unit,
218
+ pct: max > 0 ? (value / max) * 100 : 0,
219
+ });
220
+ }
221
+
222
+ return items;
223
+ }, [billingContext, usageTotals]);
224
+
225
+ // Calculate overall health
226
+ const { overallPct, overallStatus } = useMemo(() => {
227
+ if (services.length === 0) return { overallPct: 0, overallStatus: 'green' as const };
228
+ const maxPct = Math.max(...services.map((s) => s.pct));
229
+ return {
230
+ overallPct: maxPct,
231
+ overallStatus: getStatusFromPercentage(maxPct),
232
+ };
233
+ }, [services]);
234
+
235
+ // Loading state
236
+ if (!billingContext || !usageTotals) {
237
+ return (
238
+ <div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 animate-pulse">
239
+ <div className="h-4 bg-gray-100 dark:bg-slate-800 rounded w-24 mb-3" />
240
+ <div className="h-8 bg-gray-100 dark:bg-slate-800 rounded w-32 mb-2" />
241
+ <div className="h-3 bg-gray-100 dark:bg-slate-800 rounded w-20" />
242
+ </div>
243
+ );
244
+ }
245
+
246
+ const statusDotColor = {
247
+ green: 'bg-emerald-500',
248
+ yellow: 'bg-amber-500',
249
+ red: 'bg-rose-500',
250
+ };
251
+
252
+ return (
253
+ <div
254
+ className={clsx(
255
+ 'bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 transition-all duration-200',
256
+ onClick &&
257
+ 'cursor-pointer hover:border-cyan-500/50 hover:bg-gray-100 dark:hover:bg-slate-900/70'
258
+ )}
259
+ onClick={onClick}
260
+ onKeyDown={(e) => e.key === 'Enter' && onClick?.()}
261
+ role={onClick ? 'button' : undefined}
262
+ tabIndex={onClick ? 0 : undefined}
263
+ >
264
+ {/* Header */}
265
+ <div className="flex items-center justify-between mb-3">
266
+ <div className="flex items-center gap-2">
267
+ <div className="w-8 h-8 rounded-lg bg-cyan-500/20 flex items-center justify-center">
268
+ <Gauge className="w-4 h-4 text-cyan-400" />
269
+ </div>
270
+ <span className="text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
271
+ Plan Health
272
+ </span>
273
+ </div>
274
+ <div className={clsx('w-2.5 h-2.5 rounded-full', statusDotColor[overallStatus])} />
275
+ </div>
276
+
277
+ {/* Main Value - Overall percentage (highest service) */}
278
+ <div className="flex items-baseline gap-2 mb-3">
279
+ <span className="text-2xl font-bold font-mono text-gray-900 dark:text-slate-100">
280
+ {overallPct.toFixed(0)}%
281
+ </span>
282
+ <span className="text-xs text-gray-500 dark:text-slate-500">peak usage</span>
283
+ </div>
284
+
285
+ {/* Service Breakdown */}
286
+ <div className="space-y-2">
287
+ {services.map((service) => (
288
+ <BulletChart
289
+ key={service.key}
290
+ label={service.label}
291
+ value={service.value}
292
+ max={service.max}
293
+ />
294
+ ))}
295
+ </div>
296
+
297
+ {/* Billing period countdown */}
298
+ {billingContext.countdownText && (
299
+ <p className="text-xs text-gray-500 dark:text-slate-500 mt-3 pt-2 border-t border-gray-200 dark:border-slate-700">
300
+ {billingContext.countdownText}
301
+ </p>
302
+ )}
303
+ </div>
304
+ );
305
+ }
306
+
307
+ /**
308
+ * MTD Spend Card - Shows current month-to-date spend and burn rate
309
+ */
310
+ function MTDSpendCard({
311
+ burnRate,
312
+ onClick,
313
+ }: {
314
+ burnRate: BurnRateData | null;
315
+ onClick?: () => void;
316
+ }) {
317
+ if (!burnRate) {
318
+ return (
319
+ <div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 animate-pulse">
320
+ <div className="h-4 bg-gray-100 dark:bg-slate-800 rounded w-24 mb-3" />
321
+ <div className="h-8 bg-gray-100 dark:bg-slate-800 rounded w-32 mb-2" />
322
+ <div className="h-3 bg-gray-100 dark:bg-slate-800 rounded w-20" />
323
+ </div>
324
+ );
325
+ }
326
+
327
+ const trendDirection =
328
+ burnRate.vsLastMonthPct === null ? 'neutral' : burnRate.vsLastMonthPct > 0 ? 'up' : 'down';
329
+
330
+ const statusColors: Record<string, string> = {
331
+ green: 'bg-emerald-500',
332
+ yellow: 'bg-amber-500',
333
+ red: 'bg-rose-500',
334
+ };
335
+
336
+ return (
337
+ <div
338
+ className={clsx(
339
+ 'bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 transition-all duration-200',
340
+ onClick &&
341
+ 'cursor-pointer hover:border-blue-500/50 hover:bg-gray-100 dark:hover:bg-slate-900/70'
342
+ )}
343
+ onClick={onClick}
344
+ onKeyDown={(e) => e.key === 'Enter' && onClick?.()}
345
+ role={onClick ? 'button' : undefined}
346
+ tabIndex={onClick ? 0 : undefined}
347
+ >
348
+ {/* Header */}
349
+ <div className="flex items-center justify-between mb-3">
350
+ <div className="flex items-center gap-2">
351
+ <div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
352
+ <Activity className="w-4 h-4 text-blue-400" />
353
+ </div>
354
+ <span className="text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
355
+ MTD Spend
356
+ </span>
357
+ </div>
358
+ <div className={clsx('w-2.5 h-2.5 rounded-full', statusColors[burnRate.status])} />
359
+ </div>
360
+
361
+ {/* Main Value */}
362
+ <div className="flex items-baseline gap-2 mb-2">
363
+ <span className="text-2xl font-bold font-mono text-gray-900 dark:text-slate-100">
364
+ {formatCost(burnRate.mtdCost)}
365
+ </span>
366
+ {burnRate.vsLastMonthPct !== null && (
367
+ <div
368
+ className={clsx(
369
+ 'flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-semibold',
370
+ trendDirection === 'up' && 'bg-rose-500/20 text-rose-400',
371
+ trendDirection === 'down' && 'bg-emerald-500/20 text-emerald-400',
372
+ trendDirection === 'neutral' &&
373
+ 'bg-gray-200 dark:bg-slate-700 text-gray-600 dark:text-slate-400'
374
+ )}
375
+ >
376
+ {trendDirection === 'up' && <TrendingUp className="w-3 h-3" />}
377
+ {trendDirection === 'down' && <TrendingDown className="w-3 h-3" />}
378
+ {trendDirection === 'neutral' && <Minus className="w-3 h-3" />}
379
+ <span>{Math.abs(burnRate.vsLastMonthPct).toFixed(1)}%</span>
380
+ </div>
381
+ )}
382
+ </div>
383
+
384
+ {/* Secondary Info */}
385
+ <div className="flex items-center justify-between text-xs">
386
+ <span className="text-gray-500 dark:text-slate-500">
387
+ Projected:{' '}
388
+ <span className="text-gray-600 dark:text-slate-400">
389
+ {formatCost(burnRate.projectedMonthlyCost)}
390
+ </span>
391
+ </span>
392
+ <span className="text-gray-500 dark:text-slate-500">
393
+ {formatCost(burnRate.dailyBurnRate)}/day
394
+ </span>
395
+ </div>
396
+ </div>
397
+ );
398
+ }
399
+
400
+ /**
401
+ * Plan Utilisation Card - Shows highest service utilisation
402
+ */
403
+ function PlanUtilisationCard({
404
+ services,
405
+ onClick,
406
+ }: {
407
+ services: ServiceUtilisation[];
408
+ onClick?: (serviceId: string) => void;
409
+ }) {
410
+ const highest = useMemo(() => {
411
+ if (services.length === 0) return null;
412
+ return services.reduce(
413
+ (max, service) => (service.percentage > max.percentage ? service : max),
414
+ services[0]
415
+ );
416
+ }, [services]);
417
+
418
+ if (!highest) {
419
+ return (
420
+ <div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 animate-pulse">
421
+ <div className="h-4 bg-gray-100 dark:bg-slate-800 rounded w-24 mb-3" />
422
+ <div className="h-8 bg-gray-100 dark:bg-slate-800 rounded w-32 mb-2" />
423
+ <div className="h-3 bg-gray-100 dark:bg-slate-800 rounded w-20" />
424
+ </div>
425
+ );
426
+ }
427
+
428
+ const pct = Math.min(highest.percentage, 100);
429
+ const displayPct = highest.percentage > 999 ? '>999' : highest.percentage.toFixed(0);
430
+
431
+ const statusColor =
432
+ highest.percentage >= 90
433
+ ? 'bg-rose-500'
434
+ : highest.percentage >= 75
435
+ ? 'bg-orange-500'
436
+ : highest.percentage >= 50
437
+ ? 'bg-amber-500'
438
+ : 'bg-emerald-500';
439
+
440
+ const statusLabel =
441
+ highest.percentage >= 90
442
+ ? 'Critical'
443
+ : highest.percentage >= 75
444
+ ? 'High'
445
+ : highest.percentage >= 50
446
+ ? 'Warning'
447
+ : 'Normal';
448
+
449
+ const handleClick = () => onClick?.(highest.id);
450
+
451
+ return (
452
+ <div
453
+ className={clsx(
454
+ 'bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 transition-all duration-200',
455
+ onClick &&
456
+ 'cursor-pointer hover:border-purple-500/50 hover:bg-gray-100 dark:hover:bg-slate-900/70'
457
+ )}
458
+ onClick={handleClick}
459
+ onKeyDown={(e) => e.key === 'Enter' && handleClick()}
460
+ role={onClick ? 'button' : undefined}
461
+ tabIndex={onClick ? 0 : undefined}
462
+ >
463
+ {/* Header */}
464
+ <div className="flex items-center gap-2 mb-3">
465
+ <div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center">
466
+ <BarChart3 className="w-4 h-4 text-purple-400" />
467
+ </div>
468
+ <span className="text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
469
+ Plan Utilisation
470
+ </span>
471
+ </div>
472
+
473
+ {/* Main Value */}
474
+ <div className="flex items-baseline gap-2 mb-2">
475
+ <span className="text-2xl font-bold font-mono text-gray-900 dark:text-slate-100">
476
+ {displayPct}%
477
+ </span>
478
+ <span className="text-xs text-gray-500 dark:text-slate-500">{highest.label}</span>
479
+ </div>
480
+
481
+ {/* Progress Bar */}
482
+ <div className="w-full h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden mb-2">
483
+ <div
484
+ className={clsx('h-full rounded-full transition-all', statusColor)}
485
+ style={{ width: `${pct}%` }}
486
+ />
487
+ </div>
488
+
489
+ {/* Secondary Info */}
490
+ <div className="flex items-center justify-between text-xs">
491
+ <span className="text-slate-500">
492
+ {formatNumber(highest.current)} / {formatNumber(highest.limit)} {highest.unit}
493
+ </span>
494
+ <span
495
+ className={clsx(
496
+ 'font-semibold',
497
+ highest.percentage >= 90 && 'text-rose-400',
498
+ highest.percentage >= 75 && highest.percentage < 90 && 'text-orange-400',
499
+ highest.percentage >= 50 && highest.percentage < 75 && 'text-amber-400',
500
+ highest.percentage < 50 && 'text-emerald-400'
501
+ )}
502
+ >
503
+ {statusLabel}
504
+ </span>
505
+ </div>
506
+ </div>
507
+ );
508
+ }
509
+
510
+ /**
511
+ * Top Spender Card - Shows resource type with highest cost
512
+ */
513
+ function TopSpenderCard({
514
+ services,
515
+ onClick,
516
+ }: {
517
+ services: ServiceUtilisation[];
518
+ onClick?: () => void;
519
+ }) {
520
+ const { topService, breakdown, totalCost } = useMemo(() => {
521
+ const withCost = services
522
+ .filter((s) => s.costEstimate > 0)
523
+ .sort((a, b) => b.costEstimate - a.costEstimate);
524
+
525
+ if (withCost.length === 0) {
526
+ return { topService: null, breakdown: [], totalCost: 0 };
527
+ }
528
+
529
+ const total = withCost.reduce((sum, s) => sum + s.costEstimate, 0);
530
+ return {
531
+ topService: withCost[0],
532
+ breakdown: withCost.slice(0, 4),
533
+ totalCost: total,
534
+ };
535
+ }, [services]);
536
+
537
+ if (!topService) {
538
+ return (
539
+ <div
540
+ className={clsx(
541
+ 'bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 transition-all duration-200',
542
+ onClick &&
543
+ 'cursor-pointer hover:border-amber-500/50 hover:bg-gray-100 dark:hover:bg-slate-900/70'
544
+ )}
545
+ onClick={onClick}
546
+ onKeyDown={(e) => e.key === 'Enter' && onClick?.()}
547
+ role={onClick ? 'button' : undefined}
548
+ tabIndex={onClick ? 0 : undefined}
549
+ >
550
+ <div className="flex items-center gap-2 mb-3">
551
+ <div className="w-8 h-8 rounded-lg bg-amber-500/20 flex items-center justify-center">
552
+ <Zap className="w-4 h-4 text-amber-400" />
553
+ </div>
554
+ <span className="text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
555
+ Top Spender
556
+ </span>
557
+ </div>
558
+ <span className="text-xl font-bold font-mono text-gray-900 dark:text-slate-100">$0.00</span>
559
+ <p className="text-xs text-gray-500 dark:text-slate-500 mt-2">No costs recorded</p>
560
+ </div>
561
+ );
562
+ }
563
+
564
+ const pctOfTotal = totalCost > 0 ? (topService.costEstimate / totalCost) * 100 : 0;
565
+
566
+ return (
567
+ <div
568
+ className={clsx(
569
+ 'bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 transition-all duration-200',
570
+ onClick &&
571
+ 'cursor-pointer hover:border-amber-500/50 hover:bg-gray-100 dark:hover:bg-slate-900/70'
572
+ )}
573
+ onClick={onClick}
574
+ onKeyDown={(e) => e.key === 'Enter' && onClick?.()}
575
+ role={onClick ? 'button' : undefined}
576
+ tabIndex={onClick ? 0 : undefined}
577
+ >
578
+ {/* Header */}
579
+ <div className="flex items-center gap-2 mb-3">
580
+ <div className="w-8 h-8 rounded-lg bg-amber-500/20 flex items-center justify-center">
581
+ <Zap className="w-4 h-4 text-amber-400" />
582
+ </div>
583
+ <span className="text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
584
+ Top Spender
585
+ </span>
586
+ </div>
587
+
588
+ {/* Main Value */}
589
+ <div className="flex items-baseline gap-2 mb-1">
590
+ <span className="text-2xl font-bold font-mono text-gray-900 dark:text-slate-100">
591
+ {formatCost(topService.costEstimate)}
592
+ </span>
593
+ <span className="text-xs text-gray-500 dark:text-slate-500">
594
+ {pctOfTotal.toFixed(0)}% of total
595
+ </span>
596
+ </div>
597
+ <p className="text-sm font-medium text-amber-400 mb-3">
598
+ {SERVICE_LABELS[topService.id] || topService.label}
599
+ </p>
600
+
601
+ {/* Breakdown Mini-Bars */}
602
+ <div className="space-y-1.5">
603
+ {breakdown.map((service) => {
604
+ const widthPct = (service.costEstimate / topService.costEstimate) * 100;
605
+ const color = SERVICE_COLORS[service.id] || 'bg-slate-500';
606
+
607
+ return (
608
+ <div key={service.id} className="flex items-center gap-2 text-xs">
609
+ <span
610
+ className="w-20 truncate text-gray-500 dark:text-slate-500"
611
+ title={service.label}
612
+ >
613
+ {SERVICE_LABELS[service.id] || service.label}
614
+ </span>
615
+ <div className="flex-1 h-1 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
616
+ <div
617
+ className={clsx('h-full rounded-full', color)}
618
+ style={{ width: `${widthPct}%` }}
619
+ />
620
+ </div>
621
+ <span className="w-12 text-right text-gray-500 dark:text-slate-500">
622
+ {formatCost(service.costEstimate)}
623
+ </span>
624
+ </div>
625
+ );
626
+ })}
627
+ </div>
628
+ </div>
629
+ );
630
+ }
631
+
632
+ /**
633
+ * System Health Card - Shows operational status summary
634
+ */
635
+ function SystemHealthCard({
636
+ projects,
637
+ onClick,
638
+ }: {
639
+ projects: ProjectTableRow[];
640
+ onClick?: (filter: 'critical' | 'warning' | 'all') => void;
641
+ }) {
642
+ const health = useMemo(() => {
643
+ return projects.reduce(
644
+ (acc, p) => {
645
+ if (p.status === 'STOP') acc.critical++;
646
+ else if (p.status === 'WARN') acc.warning++;
647
+ else acc.healthy++;
648
+ return acc;
649
+ },
650
+ { critical: 0, warning: 0, healthy: 0 }
651
+ );
652
+ }, [projects]);
653
+
654
+ const totalProjects = projects.length;
655
+ const healthyPct = totalProjects > 0 ? (health.healthy / totalProjects) * 100 : 100;
656
+
657
+ // Determine overall status
658
+ const overallStatus =
659
+ health.critical > 0 ? 'critical' : health.warning > 0 ? 'warning' : 'healthy';
660
+
661
+ const statusColors: Record<string, string> = {
662
+ critical: 'bg-rose-500',
663
+ warning: 'bg-amber-500',
664
+ healthy: 'bg-emerald-500',
665
+ };
666
+
667
+ // Click filters to the most relevant status
668
+ const handleClick = () => {
669
+ if (health.critical > 0) onClick?.('critical');
670
+ else if (health.warning > 0) onClick?.('warning');
671
+ else onClick?.('all');
672
+ };
673
+
674
+ return (
675
+ <div
676
+ className={clsx(
677
+ 'bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-lg p-4 transition-all duration-200',
678
+ onClick &&
679
+ 'cursor-pointer hover:border-emerald-500/50 hover:bg-gray-100 dark:hover:bg-slate-900/70'
680
+ )}
681
+ onClick={handleClick}
682
+ onKeyDown={(e) => e.key === 'Enter' && handleClick()}
683
+ role={onClick ? 'button' : undefined}
684
+ tabIndex={onClick ? 0 : undefined}
685
+ >
686
+ {/* Header */}
687
+ <div className="flex items-center justify-between mb-3">
688
+ <div className="flex items-center gap-2">
689
+ <div className="w-8 h-8 rounded-lg bg-emerald-500/20 flex items-center justify-center">
690
+ <Shield className="w-4 h-4 text-emerald-400" />
691
+ </div>
692
+ <span className="text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
693
+ System Health
694
+ </span>
695
+ </div>
696
+ <div className={clsx('w-2.5 h-2.5 rounded-full', statusColors[overallStatus])} />
697
+ </div>
698
+
699
+ {/* Main Value */}
700
+ <div className="flex items-baseline gap-2 mb-2">
701
+ <span className="text-2xl font-bold font-mono text-gray-900 dark:text-slate-100">
702
+ {healthyPct.toFixed(0)}%
703
+ </span>
704
+ <span className="text-xs text-gray-500 dark:text-slate-500">healthy</span>
705
+ </div>
706
+
707
+ {/* Status Breakdown */}
708
+ <div className="flex items-center gap-4 text-xs">
709
+ {health.critical > 0 && (
710
+ <div className="flex items-center gap-1.5">
711
+ <div className="w-2 h-2 rounded-full bg-rose-500" />
712
+ <span className="text-rose-400 font-semibold">{health.critical} Critical</span>
713
+ </div>
714
+ )}
715
+ {health.warning > 0 && (
716
+ <div className="flex items-center gap-1.5">
717
+ <div className="w-2 h-2 rounded-full bg-amber-500" />
718
+ <span className="text-amber-400 font-semibold">{health.warning} Warning</span>
719
+ </div>
720
+ )}
721
+ <div className="flex items-center gap-1.5">
722
+ <div className="w-2 h-2 rounded-full bg-emerald-500" />
723
+ <span className="text-emerald-400 font-semibold">{health.healthy} Healthy</span>
724
+ </div>
725
+ </div>
726
+ </div>
727
+ );
728
+ }
729
+
730
+ export function HeroCardsRow({
731
+ burnRate,
732
+ services,
733
+ projects = [],
734
+ billingContext,
735
+ usageTotals,
736
+ onMTDSpendClick,
737
+ onPlanUtilisationClick,
738
+ onPlanHealthClick,
739
+ onTopSpenderClick,
740
+ onSystemHealthClick,
741
+ }: HeroCardsRowProps) {
742
+ return (
743
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
744
+ <MTDSpendCard burnRate={burnRate} onClick={onMTDSpendClick} />
745
+ <PlanHealthCard
746
+ billingContext={billingContext ?? null}
747
+ usageTotals={usageTotals}
748
+ onClick={onPlanHealthClick}
749
+ />
750
+ <PlanUtilisationCard services={services} onClick={onPlanUtilisationClick} />
751
+ <TopSpenderCard services={services} onClick={onTopSpenderClick} />
752
+ <SystemHealthCard projects={projects} onClick={onSystemHealthClick} />
753
+ </div>
754
+ );
755
+ }
756
+
757
+ export default HeroCardsRow;