@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,893 @@
1
+ /**
2
+ * UnifiedShell Component
3
+ *
4
+ * Main orchestrator for the Unified Observability Dashboard.
5
+ * Handles data fetching, state management, auto-refresh, and URL sync.
6
+ *
7
+ * Industrial Command Centre aesthetic - data-dense, dark-first design.
8
+ */
9
+
10
+ import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
11
+ import { AlertTriangle } from 'lucide-react';
12
+ import { fetchWithDedup, clearFetchCache } from '../../../lib/usage/fetchWithDedup';
13
+ import { CF_ALLOWANCES, type ServiceType } from '../../../lib/usage/allowance-config';
14
+ import type {
15
+ Period,
16
+ Tab,
17
+ SortField,
18
+ SortDir,
19
+ BurnRateData,
20
+ ServiceUtilisation,
21
+ ProjectSummary,
22
+ ProjectTableRow,
23
+ ProjectBreakdown,
24
+ UsageRow,
25
+ StatusResponse,
26
+ QueryResponse,
27
+ UtilisationResponse,
28
+ ResourceMetric,
29
+ ResourceType,
30
+ DailyCostData,
31
+ DailyCostTotals,
32
+ OperationalStatus,
33
+ BillingContextResponse,
34
+ GranularResponse,
35
+ } from './types';
36
+ import { LiveHeader } from './LiveHeader';
37
+ import { HeroCardsRow } from './HeroCardsRow';
38
+ import { AlertBanner } from './AlertBanner';
39
+ import { Recommendations } from './Recommendations';
40
+ import { ProjectsTable } from './ProjectsTable';
41
+ import { UsageChart } from '../react/UsageChart';
42
+ import { FeatureBudgets } from './FeatureBudgets';
43
+ import { CircuitBreakerEvents } from './CircuitBreakerEvents';
44
+
45
+ interface UnifiedShellProps {
46
+ initialPeriod?: Period;
47
+ initialTab?: Tab;
48
+ initialSearch?: string;
49
+ initialSort?: SortField;
50
+ initialDir?: SortDir;
51
+ initialExpanded?: string[];
52
+ }
53
+
54
+ const REFRESH_INTERVAL = 60_000; // 60 seconds
55
+ const CACHE_TTL = 60_000; // 60 seconds for resource breakdown cache
56
+
57
+ /**
58
+ * Error state component
59
+ */
60
+ function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
61
+ return (
62
+ <div className="bg-rose-500/10 border border-rose-500/30 rounded-sm p-6 flex items-start gap-4">
63
+ <AlertTriangle className="w-5 h-5 text-rose-400 flex-shrink-0 mt-0.5" />
64
+ <div className="flex-1">
65
+ <h3 className="text-rose-800 dark:text-rose-200 font-semibold text-sm">
66
+ Failed to load data
67
+ </h3>
68
+ <p className="text-rose-700/80 dark:text-rose-300/80 text-xs mt-1 font-mono">{message}</p>
69
+ <button
70
+ type="button"
71
+ onClick={onRetry}
72
+ className="mt-3 px-3 py-1.5 bg-rose-500/20 hover:bg-rose-500/30 text-rose-800 dark:text-rose-200 text-xs font-mono rounded-sm transition-colors"
73
+ >
74
+ Retry
75
+ </button>
76
+ </div>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Loading skeleton
83
+ */
84
+ function LoadingSkeleton() {
85
+ return (
86
+ <div className="space-y-6 animate-pulse">
87
+ {/* Header skeleton */}
88
+ <div className="flex justify-between items-center">
89
+ <div className="h-8 bg-gray-100 dark:bg-slate-800 rounded w-48" />
90
+ <div className="h-8 bg-gray-100 dark:bg-slate-800 rounded w-32" />
91
+ </div>
92
+
93
+ {/* Chart skeleton */}
94
+ <div className="space-y-3">
95
+ <div className="h-5 bg-gray-100 dark:bg-slate-800 rounded w-36" />
96
+ <div className="h-48 bg-gray-50 dark:bg-slate-900 rounded-sm border border-gray-200 dark:border-slate-800" />
97
+ </div>
98
+
99
+ {/* Hero cards skeleton */}
100
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
101
+ <div className="h-32 bg-gray-100/50 dark:bg-slate-800/50 rounded-lg border border-gray-300 dark:border-slate-700" />
102
+ <div className="h-32 bg-gray-100/50 dark:bg-slate-800/50 rounded-lg border border-gray-300 dark:border-slate-700" />
103
+ <div className="h-32 bg-gray-100/50 dark:bg-slate-800/50 rounded-lg border border-gray-300 dark:border-slate-700" />
104
+ </div>
105
+
106
+ {/* Table skeleton */}
107
+ <div className="space-y-3">
108
+ <div className="h-5 bg-gray-100 dark:bg-slate-800 rounded w-24" />
109
+ <div className="h-64 bg-gray-50 dark:bg-slate-900 rounded-sm border border-gray-200 dark:border-slate-800" />
110
+ </div>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ export function UnifiedShell({
116
+ initialPeriod = '24h',
117
+ initialTab = 'overview',
118
+ initialSearch = '',
119
+ initialSort = 'cost',
120
+ initialDir = 'desc',
121
+ initialExpanded = [],
122
+ }: UnifiedShellProps) {
123
+ // Core state
124
+ const [period, setPeriod] = useState<Period>(initialPeriod);
125
+ const [tab, setTab] = useState<Tab>(initialTab);
126
+ const [search, setSearch] = useState(initialSearch);
127
+ const [sort, setSort] = useState<SortField>(initialSort);
128
+ const [sortDir, setSortDir] = useState<SortDir>(initialDir);
129
+ const [expanded, setExpanded] = useState<Set<string>>(new Set(initialExpanded));
130
+
131
+ // Loading & error state
132
+ const [loading, setLoading] = useState(true);
133
+ const [isRefreshing, setIsRefreshing] = useState(false);
134
+ const [error, setError] = useState<string | null>(null);
135
+
136
+ // Data state
137
+ const [burnRate, setBurnRate] = useState<BurnRateData | null>(null);
138
+ const [services, setServices] = useState<ServiceUtilisation[]>([]);
139
+ const [projects, setProjects] = useState<ProjectSummary[]>([]);
140
+ const [usageData, setUsageData] = useState<UsageRow[]>([]);
141
+ const [statusMap, setStatusMap] = useState<
142
+ Record<string, { status: string; circuitBreaker: string }>
143
+ >({});
144
+
145
+ // Billing context state (for Plan Health cards)
146
+ const [billingContext, setBillingContext] = useState<BillingContextResponse | null>(null);
147
+
148
+ // Granular usage data (for stacked bar chart)
149
+ const [granularData, setGranularData] = useState<GranularResponse | null>(null);
150
+
151
+ // Lazy-loaded resource breakdown cache
152
+ const [resourceCache, setResourceCache] = useState<Map<string, ProjectBreakdown>>(new Map());
153
+ // Use ref to avoid dependency cycle in fetchResourceBreakdown
154
+ const resourceCacheRef = useRef(resourceCache);
155
+ resourceCacheRef.current = resourceCache;
156
+
157
+ // Dismissed alerts (persisted to localStorage)
158
+ const [dismissedAlerts, setDismissedAlerts] = useState<Set<string>>(() => {
159
+ if (typeof window === 'undefined') return new Set();
160
+ try {
161
+ const stored = localStorage.getItem('unified-dismissed-alerts');
162
+ return stored ? new Set(JSON.parse(stored)) : new Set();
163
+ } catch {
164
+ return new Set();
165
+ }
166
+ });
167
+
168
+ /**
169
+ * Sync state to URL
170
+ */
171
+ const syncToURL = useCallback(() => {
172
+ const url = new URL(window.location.href);
173
+ url.searchParams.set('period', period);
174
+ url.searchParams.set('tab', tab);
175
+
176
+ if (search) {
177
+ url.searchParams.set('search', search);
178
+ } else {
179
+ url.searchParams.delete('search');
180
+ }
181
+
182
+ url.searchParams.set('sort', sort);
183
+ url.searchParams.set('dir', sortDir);
184
+
185
+ if (expanded.size > 0) {
186
+ url.searchParams.set('expanded', Array.from(expanded).join(','));
187
+ } else {
188
+ url.searchParams.delete('expanded');
189
+ }
190
+
191
+ history.replaceState(null, '', url.toString());
192
+ }, [period, tab, search, sort, sortDir, expanded]);
193
+
194
+ // Sync URL on state change
195
+ useEffect(() => {
196
+ syncToURL();
197
+ }, [syncToURL]);
198
+
199
+ /**
200
+ * Fetch utilisation data (hero cards + projects)
201
+ */
202
+ const fetchUtilisation = useCallback(async () => {
203
+ const data = await fetchWithDedup<UtilisationResponse>('/api/usage/utilization');
204
+ if (!data.success) throw new Error('Utilisation API returned unsuccessful response');
205
+
206
+ setBurnRate(data.burnRate);
207
+ setServices(data.cloudflareServices);
208
+ setProjects(data.projects);
209
+ }, []);
210
+
211
+ /**
212
+ * Fetch status data (circuit breakers, operational status)
213
+ */
214
+ const fetchStatus = useCallback(async () => {
215
+ const data = await fetchWithDedup<StatusResponse>(`/api/usage/status?period=${period}`);
216
+ if (!data.success) throw new Error('Status API returned unsuccessful response');
217
+
218
+ setStatusMap(data.projects as Record<string, { status: string; circuitBreaker: string }>);
219
+ }, [period]);
220
+
221
+ /**
222
+ * Fetch usage query data (chart)
223
+ * Uses hour grouping for 24h period, day grouping for longer periods
224
+ */
225
+ const fetchQuery = useCallback(async () => {
226
+ // Use hourly granularity for 24h, daily for longer periods
227
+ const groupBy = period === '24h' ? 'hour' : 'day';
228
+ const data = await fetchWithDedup<QueryResponse>(
229
+ `/api/usage/query?period=${period}&groupBy=${groupBy}`
230
+ );
231
+ if (!data.success) throw new Error('Query API returned unsuccessful response');
232
+
233
+ setUsageData(data.data);
234
+ }, [period]);
235
+
236
+ /**
237
+ * Fetch billing context (for Plan Health cards)
238
+ */
239
+ const fetchBillingContext = useCallback(async () => {
240
+ const data = await fetchWithDedup<BillingContextResponse>(
241
+ `/api/usage/billing-context?period=${period}`
242
+ );
243
+ if (!data.success) throw new Error('Billing context API returned unsuccessful response');
244
+
245
+ setBillingContext(data);
246
+ }, [period]);
247
+
248
+ /**
249
+ * Fetch granular usage data (for stacked bar chart)
250
+ */
251
+ const fetchGranular = useCallback(async () => {
252
+ const data = await fetchWithDedup<GranularResponse>(`/api/usage/granular?period=${period}`);
253
+ if (!data.success) throw new Error('Granular API returned unsuccessful response');
254
+
255
+ setGranularData(data);
256
+ }, [period]);
257
+
258
+ /**
259
+ * Main data fetch
260
+ */
261
+ const fetchData = useCallback(
262
+ async (isBackground = false) => {
263
+ if (isBackground) {
264
+ setIsRefreshing(true);
265
+ } else {
266
+ setLoading(true);
267
+ setError(null);
268
+ }
269
+
270
+ try {
271
+ await Promise.all([
272
+ fetchUtilisation(),
273
+ fetchStatus(),
274
+ fetchQuery(),
275
+ fetchBillingContext(),
276
+ fetchGranular(),
277
+ ]);
278
+ setError(null);
279
+ } catch (err) {
280
+ if (!isBackground) {
281
+ setError(err instanceof Error ? err.message : 'Unknown error occurred');
282
+ }
283
+ console.error('[UnifiedShell] Fetch error:', err);
284
+ } finally {
285
+ setLoading(false);
286
+ setIsRefreshing(false);
287
+ }
288
+ },
289
+ [fetchUtilisation, fetchStatus, fetchQuery, fetchBillingContext, fetchGranular]
290
+ );
291
+
292
+ /**
293
+ * Fetch resource breakdown for a project (lazy loading)
294
+ * Uses ref for cache check to avoid dependency cycle
295
+ */
296
+ const fetchResourceBreakdown = useCallback(
297
+ async (projectId: string): Promise<ProjectBreakdown | null> => {
298
+ // Check cache first (use ref to avoid dependency cycle)
299
+ const cached = resourceCacheRef.current.get(projectId);
300
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
301
+ return cached;
302
+ }
303
+
304
+ try {
305
+ const response = await fetch(`/api/usage/daily?period=${period}&project=${projectId}`, {
306
+ credentials: 'include',
307
+ });
308
+
309
+ if (!response.ok) {
310
+ console.error(`Failed to fetch breakdown for ${projectId}: ${response.status}`);
311
+ return null;
312
+ }
313
+
314
+ const data = await response.json();
315
+ if (!data.success) return null;
316
+
317
+ // Transform the API response (object with totals) into ResourceMetric[] format
318
+ const dailyData = data.data as DailyCostData;
319
+ const resources: ResourceMetric[] = [];
320
+ const totals: DailyCostTotals = dailyData?.totals || {
321
+ workers: 0,
322
+ d1: 0,
323
+ kv: 0,
324
+ r2: 0,
325
+ durableObjects: 0,
326
+ vectorize: 0,
327
+ queues: 0,
328
+ total: 0,
329
+ };
330
+ const days = dailyData?.days || [];
331
+
332
+ // Build sparkline trends from daily data (last 6 points)
333
+ const buildTrend = (field: keyof DailyCostTotals): number[] => {
334
+ if (days.length === 0) return [];
335
+ const recentDays = days.slice(-6);
336
+ return recentDays.map((d) => d[field] || 0);
337
+ };
338
+
339
+ // Cost-to-usage conversion factors (reverse-engineered from Cloudflare pricing)
340
+ // These convert dollar costs back to approximate usage units
341
+ const costToUsage = (cost: number, type: ResourceType): number => {
342
+ switch (type) {
343
+ case 'd1':
344
+ // D1: $0.75 per 1M rows written → cost * 1M / 0.75 ≈ cost * 1.33M
345
+ return cost * 1_333_333;
346
+ case 'kv':
347
+ // KV: $5 per 1M writes → cost * 1M / 5 = cost * 200K
348
+ return (cost / 5) * 1_000_000;
349
+ case 'workers':
350
+ // Workers: $0.30 per 1M requests → cost * 1M / 0.30 ≈ cost * 3.33M
351
+ return (cost / 0.3) * 1_000_000;
352
+ case 'vectorize':
353
+ // Vectorize: $0.01 per 1M dimensions queried → cost * 1M / 0.01 = cost * 100M
354
+ return (cost / 0.01) * 1_000_000;
355
+ case 'r2':
356
+ // R2: $4.50 per 1M Class A ops → cost * 1M / 4.5 ≈ cost * 222K
357
+ return (cost / 4.5) * 1_000_000;
358
+ case 'durableObjects':
359
+ // DO: $0.15 per 1M requests → cost * 1M / 0.15 ≈ cost * 6.67M
360
+ return (cost / 0.15) * 1_000_000;
361
+ case 'queues':
362
+ // Queues: $0.40 per 1M messages → cost * 1M / 0.4 = cost * 2.5M
363
+ return (cost / 0.4) * 1_000_000;
364
+ default:
365
+ return cost;
366
+ }
367
+ };
368
+
369
+ // Map each service with cost > 0 to a ResourceMetric
370
+ const serviceMap: Array<{
371
+ key: keyof DailyCostTotals;
372
+ type: ResourceType;
373
+ label: string;
374
+ unit: string;
375
+ }> = [
376
+ { key: 'd1', type: 'd1', label: 'D1 Database', unit: 'rows written' },
377
+ { key: 'kv', type: 'kv', label: 'KV Storage', unit: 'writes' },
378
+ { key: 'r2', type: 'r2', label: 'R2 Storage', unit: 'Class A ops' },
379
+ { key: 'workers', type: 'workers', label: 'Workers', unit: 'requests' },
380
+ {
381
+ key: 'durableObjects',
382
+ type: 'durableObjects',
383
+ label: 'Durable Objects',
384
+ unit: 'requests',
385
+ },
386
+ { key: 'vectorize', type: 'vectorize', label: 'Vectorize', unit: 'dimensions' },
387
+ { key: 'queues', type: 'queues', label: 'Queues', unit: 'messages' },
388
+ ];
389
+
390
+ for (const svc of serviceMap) {
391
+ const cost = totals[svc.key] || 0;
392
+ if (cost > 0 || days.some((d) => (d[svc.key] || 0) > 0)) {
393
+ const usage = costToUsage(cost, svc.type);
394
+ const allowance = CF_ALLOWANCES[svc.type as ServiceType];
395
+ const limit = allowance?.monthlyLimit ?? Infinity;
396
+ const limitPct = limit !== Infinity && limit > 0 ? (usage / limit) * 100 : undefined;
397
+
398
+ // Format usage with appropriate units
399
+ let usageFormatted: string;
400
+ if (usage >= 1_000_000) {
401
+ usageFormatted = `${(usage / 1_000_000).toFixed(1)}M ${svc.unit}`;
402
+ } else if (usage >= 1_000) {
403
+ usageFormatted = `${(usage / 1_000).toFixed(1)}K ${svc.unit}`;
404
+ } else {
405
+ usageFormatted = `${Math.round(usage)} ${svc.unit}`;
406
+ }
407
+
408
+ resources.push({
409
+ type: svc.type,
410
+ label: svc.label,
411
+ cost,
412
+ usage,
413
+ usageFormatted,
414
+ unit: svc.unit,
415
+ limit,
416
+ limitPct,
417
+ trend: buildTrend(svc.key),
418
+ });
419
+ }
420
+ }
421
+
422
+ const breakdown: ProjectBreakdown = {
423
+ projectId,
424
+ resources,
425
+ totalCost: totals.total || 0,
426
+ fetchedAt: Date.now(),
427
+ };
428
+
429
+ setResourceCache((prev) => new Map(prev).set(projectId, breakdown));
430
+ return breakdown;
431
+ } catch (err) {
432
+ console.error(`Error fetching breakdown for ${projectId}:`, err);
433
+ return null;
434
+ }
435
+ },
436
+ [period]
437
+ );
438
+
439
+ // Initial fetch and refetch on period change
440
+ // Note: fetchData already depends on period via fetchStatus/fetchQuery
441
+ // Intentionally only depending on `period` to avoid refetch loops
442
+ useEffect(() => {
443
+ fetchData();
444
+ }, [period]);
445
+
446
+ // Auto-refresh interval
447
+ useEffect(() => {
448
+ const interval = setInterval(() => {
449
+ fetchData(true);
450
+ }, REFRESH_INTERVAL);
451
+
452
+ return () => clearInterval(interval);
453
+ }, [fetchData]);
454
+
455
+ /**
456
+ * Handle period change
457
+ */
458
+ const handlePeriodChange = useCallback((newPeriod: Period) => {
459
+ setPeriod(newPeriod);
460
+ // Clear resource cache on period change
461
+ setResourceCache(new Map());
462
+ // Clear fetch dedup cache to ensure fresh data for new period
463
+ clearFetchCache('/api/usage/');
464
+ }, []);
465
+
466
+ /**
467
+ * Handle search change (debounced in LiveHeader)
468
+ */
469
+ const handleSearchChange = useCallback((value: string) => {
470
+ setSearch(value);
471
+ }, []);
472
+
473
+ /**
474
+ * Handle sort change
475
+ */
476
+ const handleSortChange = useCallback(
477
+ (field: SortField) => {
478
+ if (field === sort) {
479
+ // Toggle direction
480
+ setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
481
+ } else {
482
+ setSort(field);
483
+ setSortDir('desc');
484
+ }
485
+ },
486
+ [sort]
487
+ );
488
+
489
+ /**
490
+ * Handle row expand/collapse
491
+ * Side effect (fetchResourceBreakdown) is called outside setState to avoid anti-pattern
492
+ */
493
+ const handleExpand = useCallback(
494
+ (projectId: string) => {
495
+ setExpanded((prev) => {
496
+ const next = new Set(prev);
497
+ const wasExpanded = next.has(projectId);
498
+
499
+ if (wasExpanded) {
500
+ next.delete(projectId);
501
+ } else {
502
+ next.add(projectId);
503
+ }
504
+
505
+ // Schedule lazy load outside of setState (after render)
506
+ if (!wasExpanded) {
507
+ // Use queueMicrotask to ensure state update completes first
508
+ queueMicrotask(() => {
509
+ fetchResourceBreakdown(projectId);
510
+ });
511
+ }
512
+
513
+ return next;
514
+ });
515
+ },
516
+ [fetchResourceBreakdown]
517
+ );
518
+
519
+ /**
520
+ * Handle tab change
521
+ */
522
+ const handleTabChange = useCallback((newTab: Tab) => {
523
+ setTab(newTab);
524
+ }, []);
525
+
526
+ /**
527
+ * Handle alert dismissal
528
+ */
529
+ const handleDismissAlert = useCallback((alertId: string) => {
530
+ setDismissedAlerts((prev) => {
531
+ const next = new Set(prev);
532
+ next.add(alertId);
533
+ // Persist to localStorage
534
+ try {
535
+ localStorage.setItem('unified-dismissed-alerts', JSON.stringify([...next]));
536
+ } catch {
537
+ // Ignore localStorage errors
538
+ }
539
+ return next;
540
+ });
541
+ }, []);
542
+
543
+ /**
544
+ * Hero Card Interactivity Handlers
545
+ */
546
+
547
+ // MTD Spend Card: Scroll to recommendations
548
+ const handleMTDSpendClick = useCallback(() => {
549
+ const el = document.getElementById('recommendations-section');
550
+ if (el) {
551
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
552
+ // Add highlight effect
553
+ el.classList.add('ring-2', 'ring-blue-500/50');
554
+ setTimeout(() => el.classList.remove('ring-2', 'ring-blue-500/50'), 2000);
555
+ }
556
+ }, []);
557
+
558
+ // Plan Utilisation Card: Show service in table, could filter or highlight
559
+ const handlePlanUtilisationClick = useCallback((_serviceId: string) => {
560
+ // Scroll to projects table
561
+ const el = document.getElementById('projects-section');
562
+ if (el) {
563
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
564
+ }
565
+ }, []);
566
+
567
+ // Top Spender Card: Sort table by cost
568
+ const handleTopSpenderClick = useCallback(() => {
569
+ setSort('cost');
570
+ setSortDir('desc');
571
+ // Scroll to projects table
572
+ const el = document.getElementById('projects-section');
573
+ if (el) {
574
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
575
+ }
576
+ }, []);
577
+
578
+ // System Health Card: Filter to critical/warning projects
579
+ const [statusFilter, setStatusFilter] = useState<'all' | 'critical' | 'warning'>('all');
580
+
581
+ const handleSystemHealthClick = useCallback((filter: 'critical' | 'warning' | 'all') => {
582
+ setStatusFilter(filter);
583
+ // Scroll to projects table
584
+ const el = document.getElementById('projects-section');
585
+ if (el) {
586
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
587
+ }
588
+ }, []);
589
+
590
+ /**
591
+ * Handle export
592
+ */
593
+ const handleExport = useCallback(() => {
594
+ if (!projects || projects.length === 0) {
595
+ console.warn('[Export] No projects data available');
596
+ return;
597
+ }
598
+
599
+ // Build CSV from current data
600
+ const rows: string[] = ['Project,MTD Cost,Delta %,Status'];
601
+ for (const proj of projects) {
602
+ // Escape any commas in project names
603
+ const name = proj.projectName.includes(',') ? `"${proj.projectName}"` : proj.projectName;
604
+ rows.push(
605
+ `${name},${proj.mtdCost.toFixed(2)},${proj.costDeltaPct.toFixed(1)},${proj.status}`
606
+ );
607
+ }
608
+
609
+ const csv = rows.join('\n');
610
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
611
+ const url = URL.createObjectURL(blob);
612
+
613
+ const link = document.createElement('a');
614
+ link.href = url;
615
+ link.download = `usage-${period}-${new Date().toISOString().split('T')[0]}.csv`;
616
+ link.style.display = 'none';
617
+ document.body.appendChild(link);
618
+ link.click();
619
+ document.body.removeChild(link);
620
+
621
+ // Cleanup
622
+ setTimeout(() => URL.revokeObjectURL(url), 100);
623
+ }, [projects, period]);
624
+
625
+ /**
626
+ * Derive operational status based on metrics
627
+ */
628
+ const deriveStatus = useCallback(
629
+ (
630
+ project: ProjectSummary,
631
+ circuitBreakerState: 'active' | 'tripped' | 'degraded' | undefined
632
+ ): OperationalStatus => {
633
+ const utilizationPct = project.utilizationPct;
634
+
635
+ // CRITICAL/STOP: Over 100% of plan limits
636
+ if (utilizationPct > 100) return 'STOP';
637
+
638
+ // WARN: Approaching limits (>80%) or circuit breaker tripped/degraded
639
+ if (
640
+ utilizationPct > 80 ||
641
+ circuitBreakerState === 'tripped' ||
642
+ circuitBreakerState === 'degraded'
643
+ ) {
644
+ return 'WARN';
645
+ }
646
+
647
+ // RUN: Operating normally
648
+ return 'RUN';
649
+ },
650
+ []
651
+ );
652
+
653
+ /**
654
+ * Calculate delta from sparkline data
655
+ */
656
+ const calculateDeltaFromSparkline = useCallback((sparkline: number[]): number => {
657
+ if (!sparkline || sparkline.length < 2) return 0;
658
+ const recent = sparkline[sparkline.length - 1];
659
+ const previous = sparkline[sparkline.length - 2];
660
+ if (previous === 0) return recent > 0 ? 100 : 0;
661
+ return ((recent - previous) / previous) * 100;
662
+ }, []);
663
+
664
+ /**
665
+ * Transform projects to table rows
666
+ */
667
+ const tableRows: ProjectTableRow[] = useMemo(() => {
668
+ let filtered = projects.map((p) => {
669
+ const cbStatus =
670
+ p.circuitBreakerStatus ||
671
+ (statusMap[p.projectId]?.circuitBreaker as 'active' | 'tripped' | 'degraded') ||
672
+ 'active';
673
+
674
+ // Derive status from utilization metrics, not just from statusMap
675
+ const derivedStatus = deriveStatus(p, cbStatus === 'active' ? undefined : cbStatus);
676
+
677
+ // Calculate delta from sparkline if costDeltaPct is 0
678
+ const delta =
679
+ p.costDeltaPct !== 0 ? p.costDeltaPct : calculateDeltaFromSparkline(p.sparklineData || []);
680
+
681
+ return {
682
+ id: p.projectId,
683
+ name: p.projectName,
684
+ status: derivedStatus,
685
+ mtdCost: p.mtdCost,
686
+ costDeltaPct: delta,
687
+ activity: p.utilizationCurrent,
688
+ activityTrend: p.sparklineData || [],
689
+ circuitBreaker: cbStatus === 'degraded' ? 'tripped' : (cbStatus as 'active' | 'tripped'),
690
+ };
691
+ });
692
+
693
+ // Apply search filter
694
+ if (search) {
695
+ const term = search.toLowerCase();
696
+ filtered = filtered.filter((r) => r.name.toLowerCase().includes(term));
697
+ }
698
+
699
+ // Apply status filter from card clicks
700
+ if (statusFilter === 'critical') {
701
+ filtered = filtered.filter((r) => r.status === 'STOP');
702
+ } else if (statusFilter === 'warning') {
703
+ filtered = filtered.filter((r) => r.status === 'WARN' || r.status === 'STOP');
704
+ }
705
+
706
+ // Apply sort
707
+ filtered.sort((a, b) => {
708
+ let cmp = 0;
709
+ if (sort === 'name') {
710
+ cmp = a.name.localeCompare(b.name);
711
+ } else if (sort === 'cost') {
712
+ cmp = a.mtdCost - b.mtdCost;
713
+ } else if (sort === 'activity') {
714
+ cmp = a.activity - b.activity;
715
+ }
716
+ return sortDir === 'desc' ? -cmp : cmp;
717
+ });
718
+
719
+ return filtered;
720
+ }, [
721
+ projects,
722
+ statusMap,
723
+ search,
724
+ sort,
725
+ sortDir,
726
+ statusFilter,
727
+ deriveStatus,
728
+ calculateDeltaFromSparkline,
729
+ ]);
730
+
731
+ // Render loading state
732
+ if (loading) {
733
+ return (
734
+ <div className="p-6 bg-white dark:bg-slate-950 min-h-screen">
735
+ <LoadingSkeleton />
736
+ </div>
737
+ );
738
+ }
739
+
740
+ // Render error state
741
+ if (error) {
742
+ return (
743
+ <div className="p-6 bg-white dark:bg-slate-950 min-h-screen">
744
+ <ErrorState message={error} onRetry={() => fetchData()} />
745
+ </div>
746
+ );
747
+ }
748
+
749
+ return (
750
+ <div className="p-6 bg-white dark:bg-slate-950 min-h-screen space-y-6">
751
+ {/* Header with controls */}
752
+ <LiveHeader
753
+ period={period}
754
+ onPeriodChange={handlePeriodChange}
755
+ search={search}
756
+ onSearchChange={handleSearchChange}
757
+ isRefreshing={isRefreshing}
758
+ onExport={handleExport}
759
+ />
760
+
761
+ {/* Tab Navigation */}
762
+ <div className="flex gap-1 p-1 bg-gray-100/50 dark:bg-slate-800/50 rounded-sm w-fit">
763
+ <button
764
+ type="button"
765
+ onClick={() => handleTabChange('overview')}
766
+ className={`px-4 py-1.5 text-xs font-mono uppercase tracking-wider rounded-sm transition-all ${
767
+ tab === 'overview'
768
+ ? 'bg-gray-200 dark:bg-slate-700 text-gray-900 dark:text-slate-100'
769
+ : 'text-gray-600 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300 hover:bg-gray-200/50 dark:hover:bg-slate-700/50'
770
+ }`}
771
+ >
772
+ Overview
773
+ </button>
774
+ <button
775
+ type="button"
776
+ onClick={() => handleTabChange('features')}
777
+ className={`px-4 py-1.5 text-xs font-mono uppercase tracking-wider rounded-sm transition-all ${
778
+ tab === 'features'
779
+ ? 'bg-gray-200 dark:bg-slate-700 text-gray-900 dark:text-slate-100'
780
+ : 'text-gray-600 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300 hover:bg-gray-200/50 dark:hover:bg-slate-700/50'
781
+ }`}
782
+ >
783
+ Features
784
+ </button>
785
+ </div>
786
+
787
+ {/* Alert Banner - shows critical alerts */}
788
+ <AlertBanner
789
+ services={services}
790
+ burnRate={burnRate}
791
+ onDismiss={handleDismissAlert}
792
+ dismissedAlerts={dismissedAlerts}
793
+ />
794
+
795
+ {tab === 'overview' && (
796
+ <>
797
+ {/* Activity Chart */}
798
+ <section>
799
+ <h2 className="text-sm font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider mb-3">
800
+ Activity Timeline
801
+ </h2>
802
+ <UsageChart
803
+ data={usageData}
804
+ loading={isRefreshing && usageData.length === 0}
805
+ period={period}
806
+ granularData={granularData}
807
+ chartType="stacked"
808
+ />
809
+ </section>
810
+
811
+ {/* Hero Cards */}
812
+ <HeroCardsRow
813
+ burnRate={burnRate}
814
+ services={services}
815
+ projects={tableRows}
816
+ billingContext={billingContext}
817
+ usageTotals={
818
+ granularData?.totals?.byTool
819
+ ? (Object.fromEntries(
820
+ Object.entries(granularData.totals.byTool).map(([k, v]) => [k, v.requests])
821
+ ) as Record<
822
+ | 'workers'
823
+ | 'd1'
824
+ | 'kv'
825
+ | 'r2'
826
+ | 'vectorize'
827
+ | 'durableObjects'
828
+ | 'queues'
829
+ | 'workersAI'
830
+ | 'pages',
831
+ number
832
+ >)
833
+ : undefined
834
+ }
835
+ onMTDSpendClick={handleMTDSpendClick}
836
+ onPlanUtilisationClick={handlePlanUtilisationClick}
837
+ onTopSpenderClick={handleTopSpenderClick}
838
+ onSystemHealthClick={handleSystemHealthClick}
839
+ />
840
+
841
+ {/* Projects Table */}
842
+ <section id="projects-section">
843
+ <div className="flex items-center justify-between mb-3">
844
+ <h2 className="text-sm font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
845
+ Projects
846
+ </h2>
847
+ {statusFilter !== 'all' && (
848
+ <button
849
+ type="button"
850
+ onClick={() => setStatusFilter('all')}
851
+ className="text-xs text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
852
+ >
853
+ Clear filter ({statusFilter})
854
+ </button>
855
+ )}
856
+ </div>
857
+ <ProjectsTable
858
+ rows={tableRows}
859
+ expanded={expanded}
860
+ onExpand={handleExpand}
861
+ onSort={handleSortChange}
862
+ sort={sort}
863
+ sortDir={sortDir}
864
+ resourceCache={resourceCache}
865
+ fetchResourceBreakdown={fetchResourceBreakdown}
866
+ />
867
+ </section>
868
+
869
+ {/* Recommendations */}
870
+ <section id="recommendations-section" className="transition-all duration-300 rounded-sm">
871
+ <Recommendations services={services} projects={projects} burnRate={burnRate} />
872
+ </section>
873
+ </>
874
+ )}
875
+
876
+ {tab === 'features' && (
877
+ <div className="space-y-6">
878
+ {/* Feature Budgets Table */}
879
+ <section>
880
+ <FeatureBudgets />
881
+ </section>
882
+
883
+ {/* Circuit Breaker Events Log */}
884
+ <section>
885
+ <CircuitBreakerEvents />
886
+ </section>
887
+ </div>
888
+ )}
889
+ </div>
890
+ );
891
+ }
892
+
893
+ export default UnifiedShell;