@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,478 @@
1
+ /**
2
+ * Usage Data Transformers
3
+ *
4
+ * Functions to transform API response data into component formats.
5
+ * Part of task-17.9 (Restructure page layout).
6
+ */
7
+
8
+ import type { UnifiedResource, ResourceType, ResourceStatus } from './types';
9
+ import { CF_PRICING } from '../../lib/cloudflare/costs';
10
+
11
+ /**
12
+ * Transform API usage data into UnifiedResource array for the table.
13
+ * Consolidates all resource types (workers, D1, KV, R2, etc.) into a single list.
14
+ */
15
+ export function transformToUnifiedResources(
16
+ data: {
17
+ workers?: Array<{
18
+ scriptName: string;
19
+ requests: number;
20
+ cpuTime: number;
21
+ duration: number;
22
+ errors: number;
23
+ }>;
24
+ d1?: Array<{
25
+ databaseId: string;
26
+ databaseName: string;
27
+ rowsRead: number;
28
+ rowsWritten: number;
29
+ queryCount: number;
30
+ }>;
31
+ kv?: Array<{
32
+ namespaceId: string;
33
+ namespaceName: string;
34
+ reads: number;
35
+ writes: number;
36
+ deletes: number;
37
+ lists: number;
38
+ }>;
39
+ r2?: Array<{
40
+ bucketName: string;
41
+ storageBytes: number;
42
+ objectCount: number;
43
+ classAOperations: number;
44
+ classBOperations: number;
45
+ }>;
46
+ vectorize?: Array<{
47
+ id: string;
48
+ name: string;
49
+ vectorCount: number;
50
+ dimensions: number;
51
+ }>;
52
+ pages?: Array<{
53
+ projectName: string;
54
+ deployments: number;
55
+ requests: number;
56
+ bandwidth: number;
57
+ }>;
58
+ durableObjects?: Array<{
59
+ name: string;
60
+ requests: number;
61
+ duration: number;
62
+ storageBytes: number;
63
+ }>;
64
+ aiGateway?: {
65
+ totalRequests: number;
66
+ totalTokens: number;
67
+ cachedRequests: number;
68
+ modelBreakdown: Array<{
69
+ model: string;
70
+ requests: number;
71
+ tokens: number;
72
+ }>;
73
+ };
74
+ },
75
+ costs: {
76
+ workers: number;
77
+ d1: number;
78
+ kv: number;
79
+ r2: number;
80
+ vectorize: number;
81
+ pages: number;
82
+ queues: number;
83
+ workflows: number;
84
+ durableObjects: number;
85
+ aiGateway: number;
86
+ total: number;
87
+ },
88
+ projectMapping: (name: string) => string
89
+ ): UnifiedResource[] {
90
+ const resources: UnifiedResource[] = [];
91
+
92
+ // Transform Workers - calculate cost based on actual requests and CPU time
93
+ if (data.workers) {
94
+ for (const worker of data.workers) {
95
+ // Workers pricing: $0.30 per million requests + $0.02 per million CPU ms
96
+ // Note: $5/mo base cost is account-level, not per-worker
97
+ const requestCost = (worker.requests / 1_000_000) * CF_PRICING.workers.requestsPerMillion;
98
+ const cpuCost = (worker.cpuTime / 1_000_000) * CF_PRICING.workers.cpuMsPerMillion;
99
+ const costPerWorker = requestCost + cpuCost;
100
+ resources.push({
101
+ id: `worker-${worker.scriptName}`,
102
+ name: worker.scriptName,
103
+ type: 'worker' as ResourceType,
104
+ project: projectMapping(worker.scriptName),
105
+ usage: {
106
+ value: worker.requests,
107
+ unit: 'requests',
108
+ formatted: formatNumber(worker.requests),
109
+ },
110
+ costCurrent: costPerWorker,
111
+ costPrior: 0, // Will be filled by comparison data
112
+ costDelta: 0,
113
+ costDeltaPct: null,
114
+ status: getStatusFromThreshold(worker.errors, worker.requests),
115
+ });
116
+ }
117
+ }
118
+
119
+ // Transform D1 databases - calculate cost based on actual rowsRead/rowsWritten
120
+ if (data.d1) {
121
+ for (const db of data.d1) {
122
+ // D1 pricing: $0.001 per billion rows read + $1.00 per million rows written
123
+ const costPerDb =
124
+ (db.rowsRead / 1_000_000_000) * CF_PRICING.d1.rowsReadPerBillion +
125
+ (db.rowsWritten / 1_000_000) * CF_PRICING.d1.rowsWrittenPerMillion;
126
+ resources.push({
127
+ id: `d1-${db.databaseId}`,
128
+ name: db.databaseName || db.databaseId,
129
+ type: 'd1' as ResourceType,
130
+ project: projectMapping(db.databaseName || db.databaseId),
131
+ usage: {
132
+ value: db.rowsRead,
133
+ unit: 'rows read',
134
+ formatted: formatNumber(db.rowsRead),
135
+ },
136
+ costCurrent: costPerDb,
137
+ costPrior: 0,
138
+ costDelta: 0,
139
+ costDeltaPct: null,
140
+ status: 'healthy' as ResourceStatus,
141
+ });
142
+ }
143
+ }
144
+
145
+ // Transform KV namespaces - calculate cost based on actual reads/writes/deletes/lists
146
+ if (data.kv) {
147
+ for (const kv of data.kv) {
148
+ // KV pricing: $0.50/M reads + $5.00/M writes + $5.00/M deletes + $5.00/M lists
149
+ const costPerKv =
150
+ (kv.reads / 1_000_000) * CF_PRICING.kv.readsPerMillion +
151
+ (kv.writes / 1_000_000) * CF_PRICING.kv.writesPerMillion +
152
+ (kv.deletes / 1_000_000) * CF_PRICING.kv.deletesPerMillion +
153
+ (kv.lists / 1_000_000) * CF_PRICING.kv.listsPerMillion;
154
+ const totalOps = kv.reads + kv.writes + kv.deletes + kv.lists;
155
+ resources.push({
156
+ id: `kv-${kv.namespaceId}`,
157
+ name: kv.namespaceName || kv.namespaceId,
158
+ type: 'kv' as ResourceType,
159
+ project: projectMapping(kv.namespaceName || kv.namespaceId),
160
+ usage: {
161
+ value: totalOps,
162
+ unit: 'operations',
163
+ formatted: formatNumber(totalOps),
164
+ },
165
+ costCurrent: costPerKv,
166
+ costPrior: 0,
167
+ costDelta: 0,
168
+ costDeltaPct: null,
169
+ status: 'healthy' as ResourceStatus,
170
+ });
171
+ }
172
+ }
173
+
174
+ // Transform R2 buckets - calculate cost based on actual storage and operations
175
+ if (data.r2) {
176
+ for (const bucket of data.r2) {
177
+ // R2 pricing: $0.015/GB storage + $4.50/M Class A ops + $0.36/M Class B ops
178
+ // Note: Cloudflare uses decimal GB (1 GB = 1,000,000,000 bytes) for billing
179
+ const costPerBucket =
180
+ (bucket.storageBytes / 1_000_000_000) * CF_PRICING.r2.storagePerGbMonth +
181
+ (bucket.classAOperations / 1_000_000) * CF_PRICING.r2.classAPerMillion +
182
+ (bucket.classBOperations / 1_000_000) * CF_PRICING.r2.classBPerMillion;
183
+ resources.push({
184
+ id: `r2-${bucket.bucketName}`,
185
+ name: bucket.bucketName,
186
+ type: 'r2' as ResourceType,
187
+ project: projectMapping(bucket.bucketName),
188
+ usage: {
189
+ value: bucket.storageBytes,
190
+ unit: 'storage',
191
+ formatted: formatBytes(bucket.storageBytes),
192
+ },
193
+ costCurrent: costPerBucket,
194
+ costPrior: 0,
195
+ costDelta: 0,
196
+ costDeltaPct: null,
197
+ status: 'healthy' as ResourceStatus,
198
+ });
199
+ }
200
+ }
201
+
202
+ // Transform Vectorize indexes - calculate cost based on actual dimensions stored
203
+ if (data.vectorize) {
204
+ for (const index of data.vectorize) {
205
+ // Vectorize pricing: $0.01 per million stored dimensions
206
+ const storedDimensions = index.vectorCount * index.dimensions;
207
+ const costPerIndex =
208
+ (storedDimensions / 1_000_000) * CF_PRICING.vectorize.storedDimensionsPerMillion;
209
+ resources.push({
210
+ id: `vectorize-${index.id}`,
211
+ name: index.name || index.id,
212
+ type: 'vectorize' as ResourceType,
213
+ project: projectMapping(index.name || index.id),
214
+ usage: {
215
+ value: index.vectorCount,
216
+ unit: 'vectors',
217
+ formatted: formatNumber(index.vectorCount),
218
+ },
219
+ costCurrent: costPerIndex,
220
+ costPrior: 0,
221
+ costDelta: 0,
222
+ costDeltaPct: null,
223
+ status: 'healthy' as ResourceStatus,
224
+ });
225
+ }
226
+ }
227
+
228
+ // Transform Pages projects - calculate cost based on deployments and bandwidth
229
+ if (data.pages) {
230
+ for (const page of data.pages) {
231
+ // Pages pricing: $0.15 per build after 500 free + $0.02 per GB bandwidth
232
+ // Note: First 500 builds/month are free - assuming passed in usage is billable
233
+ // Note: Cloudflare uses decimal GB (1 GB = 1,000,000,000 bytes) for billing
234
+ const buildCost = page.deployments * CF_PRICING.pages.buildCost;
235
+ const bandwidthCost = (page.bandwidth / 1_000_000_000) * CF_PRICING.pages.bandwidthPerGb;
236
+ const costPerPage = buildCost + bandwidthCost;
237
+ resources.push({
238
+ id: `pages-${page.projectName}`,
239
+ name: page.projectName,
240
+ type: 'pages' as ResourceType,
241
+ project: projectMapping(page.projectName),
242
+ usage: {
243
+ value: page.requests,
244
+ unit: 'requests',
245
+ formatted: formatNumber(page.requests),
246
+ },
247
+ costCurrent: costPerPage,
248
+ costPrior: 0,
249
+ costDelta: 0,
250
+ costDeltaPct: null,
251
+ status: 'healthy' as ResourceStatus,
252
+ });
253
+ }
254
+ }
255
+
256
+ // Transform Durable Objects - calculate cost based on requests, duration, storage
257
+ if (data.durableObjects) {
258
+ for (const obj of data.durableObjects) {
259
+ // DO pricing: $0.15/M requests + $12.50/M GB-seconds + $0.20/GB storage
260
+ // Note: Cloudflare uses decimal GB (1 GB = 1,000,000,000 bytes) for billing
261
+ const requestCost = (obj.requests / 1_000_000) * CF_PRICING.durableObjects.requestsPerMillion;
262
+ const durationCost =
263
+ (obj.duration / 1_000_000) * CF_PRICING.durableObjects.gbSecondsPerMillion;
264
+ const storageCost =
265
+ (obj.storageBytes / 1_000_000_000) * CF_PRICING.durableObjects.storagePerGbMonth;
266
+ const costPerObj = requestCost + durationCost + storageCost;
267
+ resources.push({
268
+ id: `do-${obj.name}`,
269
+ name: obj.name,
270
+ type: 'do' as ResourceType,
271
+ project: projectMapping(obj.name),
272
+ usage: {
273
+ value: obj.requests,
274
+ unit: 'requests',
275
+ formatted: formatNumber(obj.requests),
276
+ },
277
+ costCurrent: costPerObj,
278
+ costPrior: 0,
279
+ costDelta: 0,
280
+ costDeltaPct: null,
281
+ status: 'healthy' as ResourceStatus,
282
+ });
283
+ }
284
+ }
285
+
286
+ // Transform AI Gateway (single entry)
287
+ if (data.aiGateway && data.aiGateway.totalRequests > 0) {
288
+ resources.push({
289
+ id: 'ai-gateway-main',
290
+ name: 'AI Gateway',
291
+ type: 'ai-gateway' as ResourceType,
292
+ project: 'platform', // AI Gateway project attribution
293
+ usage: {
294
+ value: data.aiGateway.totalTokens,
295
+ unit: 'tokens',
296
+ formatted: formatNumber(data.aiGateway.totalTokens),
297
+ },
298
+ costCurrent: costs.aiGateway,
299
+ costPrior: 0,
300
+ costDelta: 0,
301
+ costDeltaPct: null,
302
+ status: 'healthy' as ResourceStatus,
303
+ });
304
+ }
305
+
306
+ return resources;
307
+ }
308
+
309
+ /**
310
+ * Apply comparison data to resources.
311
+ * Updates costPrior, costDelta, and costDeltaPct for each resource.
312
+ */
313
+ export function applyComparisonData(
314
+ resources: UnifiedResource[],
315
+ priorResources: UnifiedResource[]
316
+ ): UnifiedResource[] {
317
+ const priorMap = new Map(priorResources.map((r) => [r.id, r]));
318
+
319
+ return resources.map((resource) => {
320
+ const prior = priorMap.get(resource.id);
321
+
322
+ if (!prior) {
323
+ // New resource - mark as NEW
324
+ return {
325
+ ...resource,
326
+ costDeltaPct: 'NEW' as const,
327
+ };
328
+ }
329
+
330
+ const costDelta = resource.costCurrent - prior.costCurrent;
331
+ // Use $0.01 threshold to avoid extreme percentages from near-zero baselines
332
+ // Cap at 999% for display sanity
333
+ const costDeltaPct =
334
+ prior.costCurrent >= 0.01
335
+ ? Math.min((costDelta / prior.costCurrent) * 100, 999)
336
+ : resource.costCurrent > 0
337
+ ? 'NEW'
338
+ : 0;
339
+
340
+ return {
341
+ ...resource,
342
+ costPrior: prior.costCurrent,
343
+ costDelta,
344
+ costDeltaPct:
345
+ typeof costDeltaPct === 'number' ? Math.round(costDeltaPct * 10) / 10 : costDeltaPct,
346
+ };
347
+ });
348
+ }
349
+
350
+ /**
351
+ * Filter resources based on filter state.
352
+ */
353
+ export function filterResources(
354
+ resources: UnifiedResource[],
355
+ filters: {
356
+ project?: string;
357
+ serviceTypes?: string[];
358
+ searchQuery?: string;
359
+ onlyChanged?: boolean;
360
+ nonZeroCost?: boolean;
361
+ }
362
+ ): UnifiedResource[] {
363
+ return resources.filter((resource) => {
364
+ // Project filter
365
+ if (filters.project && filters.project !== 'all' && resource.project !== filters.project) {
366
+ return false;
367
+ }
368
+
369
+ // Service type filter
370
+ if (filters.serviceTypes && filters.serviceTypes.length > 0) {
371
+ if (!filters.serviceTypes.includes(resource.type)) {
372
+ return false;
373
+ }
374
+ }
375
+
376
+ // Search query filter
377
+ if (filters.searchQuery) {
378
+ const query = filters.searchQuery.toLowerCase();
379
+ if (!resource.name.toLowerCase().includes(query)) {
380
+ return false;
381
+ }
382
+ }
383
+
384
+ // Only changed filter (>5% change or NEW)
385
+ if (filters.onlyChanged) {
386
+ if (resource.costDeltaPct === 'NEW') return true;
387
+ if (typeof resource.costDeltaPct !== 'number') return false;
388
+ if (Math.abs(resource.costDeltaPct) <= 5) return false;
389
+ }
390
+
391
+ // Non-zero cost filter
392
+ if (filters.nonZeroCost && resource.costCurrent === 0) {
393
+ return false;
394
+ }
395
+
396
+ return true;
397
+ });
398
+ }
399
+
400
+ /**
401
+ * Sort resources by column.
402
+ */
403
+ export function sortResources(
404
+ resources: UnifiedResource[],
405
+ column: string,
406
+ direction: 'asc' | 'desc'
407
+ ): UnifiedResource[] {
408
+ const sorted = [...resources].sort((a, b) => {
409
+ let comparison = 0;
410
+
411
+ switch (column) {
412
+ case 'name':
413
+ comparison = a.name.localeCompare(b.name);
414
+ break;
415
+ case 'type':
416
+ comparison = a.type.localeCompare(b.type);
417
+ break;
418
+ case 'project':
419
+ comparison = a.project.localeCompare(b.project);
420
+ break;
421
+ case 'usage':
422
+ comparison = a.usage.value - b.usage.value;
423
+ break;
424
+ case 'costCurrent':
425
+ comparison = a.costCurrent - b.costCurrent;
426
+ break;
427
+ case 'costDeltaPct': {
428
+ const aPct = a.costDeltaPct === 'NEW' ? Infinity : (a.costDeltaPct ?? -Infinity);
429
+ const bPct = b.costDeltaPct === 'NEW' ? Infinity : (b.costDeltaPct ?? -Infinity);
430
+ comparison = aPct - bPct;
431
+ break;
432
+ }
433
+ case 'status': {
434
+ const statusOrder = { critical: 4, high: 3, warning: 2, healthy: 1 };
435
+ comparison = statusOrder[a.status] - statusOrder[b.status];
436
+ break;
437
+ }
438
+ default:
439
+ comparison = 0;
440
+ }
441
+
442
+ return direction === 'asc' ? comparison : -comparison;
443
+ });
444
+
445
+ return sorted;
446
+ }
447
+
448
+ // Helper functions
449
+ function formatNumber(num: number): string {
450
+ if (num === undefined || num === null || isNaN(num)) return '0';
451
+ if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)}B`;
452
+ if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(2)}M`;
453
+ if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
454
+ return num.toLocaleString();
455
+ }
456
+
457
+ /**
458
+ * Format bytes to human-readable string using decimal (SI) units.
459
+ *
460
+ * IMPORTANT: Uses decimal (SI) units because Cloudflare bills in decimal GB:
461
+ * - 1 GB = 1,000,000,000 bytes (decimal/SI - used for billing)
462
+ * - 1 GiB = 1,073,741,824 bytes (binary - NOT used by Cloudflare)
463
+ */
464
+ function formatBytes(bytes: number): string {
465
+ if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(2)} GB`;
466
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(2)} MB`;
467
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(2)} KB`;
468
+ return `${bytes} B`;
469
+ }
470
+
471
+ function getStatusFromThreshold(errors: number, requests: number): ResourceStatus {
472
+ if (requests === 0) return 'healthy';
473
+ const errorRate = (errors / requests) * 100;
474
+ if (errorRate >= 10) return 'critical';
475
+ if (errorRate >= 5) return 'high';
476
+ if (errorRate >= 1) return 'warning';
477
+ return 'healthy';
478
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * AlertBanner Component
3
+ *
4
+ * Displays critical alerts for services approaching or exceeding limits.
5
+ * Dismissible alerts are stored in localStorage.
6
+ * Supports collapse/expand for managing alert visibility.
7
+ */
8
+
9
+ import { useState, useMemo } from 'react';
10
+ import { AlertTriangle, X, ChevronDown, ChevronUp, ChevronsUpDown } from 'lucide-react';
11
+ import { clsx } from 'clsx';
12
+ import type { ServiceUtilisation, BurnRateData } from './types';
13
+
14
+ interface AlertBannerProps {
15
+ services: ServiceUtilisation[];
16
+ burnRate: BurnRateData | null;
17
+ onDismiss: (alertId: string) => void;
18
+ dismissedAlerts: Set<string>;
19
+ }
20
+
21
+ interface AlertItem {
22
+ id: string;
23
+ type: 'budget' | 'service';
24
+ title: string;
25
+ detail: string;
26
+ }
27
+
28
+ export function AlertBanner({ services, burnRate, onDismiss, dismissedAlerts }: AlertBannerProps) {
29
+ // Collapsed state - which alerts are collapsed (show title only)
30
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
31
+ // All alerts collapsed state for the header toggle
32
+ const [allCollapsed, setAllCollapsed] = useState(false);
33
+
34
+ // Filter to critical/overage alerts that haven't been dismissed
35
+ const criticalAlerts = services
36
+ .filter((s) => s.status === 'critical' || s.status === 'overage')
37
+ .filter((s) => !dismissedAlerts.has(s.id));
38
+
39
+ // Check for budget alert
40
+ const showBudgetAlert = burnRate?.status === 'red' && !dismissedAlerts.has('budget');
41
+
42
+ // Build unified alert list for count
43
+ const alerts = useMemo(() => {
44
+ const list: AlertItem[] = [];
45
+
46
+ if (showBudgetAlert && burnRate) {
47
+ list.push({
48
+ id: 'budget',
49
+ type: 'budget',
50
+ title: 'BUDGET ALERT: Projected spend exceeds target',
51
+ detail: `Projected: $${burnRate.projectedMonthlyCost.toFixed(2)} | Daily burn: $${burnRate.dailyBurnRate.toFixed(2)}/day | ${burnRate.daysRemaining} days remaining`,
52
+ });
53
+ }
54
+
55
+ for (const alert of criticalAlerts) {
56
+ list.push({
57
+ id: alert.id,
58
+ type: 'service',
59
+ title: `CRITICAL: ${alert.label} at ${alert.percentage.toFixed(0)}% of limit`,
60
+ detail: `Current: ${alert.current.toLocaleString()} ${alert.unit} / Limit: ${alert.limit.toLocaleString()} ${alert.unit}${alert.costEstimate > 0 ? ` | Est. cost: $${alert.costEstimate.toFixed(2)}` : ''}`,
61
+ });
62
+ }
63
+
64
+ return list;
65
+ }, [showBudgetAlert, burnRate, criticalAlerts]);
66
+
67
+ if (alerts.length === 0) {
68
+ return null;
69
+ }
70
+
71
+ const toggleCollapse = (id: string) => {
72
+ setCollapsed((prev) => {
73
+ const next = new Set(prev);
74
+ if (next.has(id)) {
75
+ next.delete(id);
76
+ } else {
77
+ next.add(id);
78
+ }
79
+ return next;
80
+ });
81
+ };
82
+
83
+ const toggleAll = () => {
84
+ if (allCollapsed) {
85
+ // Expand all
86
+ setCollapsed(new Set());
87
+ setAllCollapsed(false);
88
+ } else {
89
+ // Collapse all
90
+ setCollapsed(new Set(alerts.map((a) => a.id)));
91
+ setAllCollapsed(true);
92
+ }
93
+ };
94
+
95
+ return (
96
+ <div className="mb-6">
97
+ {/* Alert Header with count and collapse toggle */}
98
+ <div className="flex items-center justify-between mb-2">
99
+ <div className="flex items-center gap-2">
100
+ <AlertTriangle className="w-4 h-4 text-rose-400" />
101
+ <span className="text-xs font-semibold text-rose-300 uppercase tracking-wider">
102
+ {alerts.length} Active Alert{alerts.length !== 1 ? 's' : ''}
103
+ </span>
104
+ </div>
105
+ <button
106
+ type="button"
107
+ onClick={toggleAll}
108
+ className="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300 transition-colors px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-slate-800"
109
+ >
110
+ <ChevronsUpDown className="w-3 h-3" />
111
+ {allCollapsed ? 'Expand All' : 'Collapse All'}
112
+ </button>
113
+ </div>
114
+
115
+ {/* Alerts List */}
116
+ <div className="space-y-2">
117
+ {alerts.map((alert) => {
118
+ const isCollapsed = collapsed.has(alert.id);
119
+
120
+ return (
121
+ <div
122
+ key={alert.id}
123
+ className="bg-rose-500/10 border border-rose-500/30 rounded-sm overflow-hidden transition-all"
124
+ >
125
+ {/* Alert Header Row - always visible */}
126
+ <div className="p-3 flex items-center gap-3">
127
+ <button
128
+ type="button"
129
+ onClick={() => toggleCollapse(alert.id)}
130
+ className="text-rose-400 hover:text-rose-300 p-1 rounded hover:bg-rose-500/20 transition-colors"
131
+ aria-label={isCollapsed ? 'Expand alert' : 'Collapse alert'}
132
+ >
133
+ {isCollapsed ? (
134
+ <ChevronDown className="w-4 h-4" />
135
+ ) : (
136
+ <ChevronUp className="w-4 h-4" />
137
+ )}
138
+ </button>
139
+ <AlertTriangle className="w-4 h-4 text-rose-400 flex-shrink-0" />
140
+ <span className="text-rose-200 font-semibold text-sm flex-1 truncate">
141
+ {alert.title}
142
+ </span>
143
+ <button
144
+ type="button"
145
+ onClick={() => onDismiss(alert.id)}
146
+ className="text-rose-400 hover:text-rose-300 p-1 rounded hover:bg-rose-500/20 transition-colors"
147
+ aria-label="Dismiss alert"
148
+ >
149
+ <X className="w-4 h-4" />
150
+ </button>
151
+ </div>
152
+
153
+ {/* Alert Details - collapsible */}
154
+ <div
155
+ className={clsx(
156
+ 'overflow-hidden transition-all duration-200 ease-in-out',
157
+ isCollapsed ? 'max-h-0' : 'max-h-24'
158
+ )}
159
+ >
160
+ <div className="px-4 pb-3 pl-12">
161
+ <p className="text-rose-300/80 text-xs font-mono">{alert.detail}</p>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ );
166
+ })}
167
+ </div>
168
+ </div>
169
+ );
170
+ }
171
+
172
+ export default AlertBanner;