@littlebearapps/platform-admin-sdk 2.1.0 → 2.2.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 (115) hide show
  1. package/README.md +2 -5
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +121 -3
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
  7. package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
  8. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  9. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  10. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  11. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  12. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  13. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  14. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  15. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  16. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  17. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  18. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  19. package/templates/full/dashboard/src/pages/map.astro +561 -0
  20. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  21. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  22. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  23. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  24. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  25. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  26. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  27. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  28. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  29. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  30. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  31. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  32. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  33. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  34. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  35. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  36. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  37. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  38. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  39. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  40. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  41. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  42. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  43. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  44. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  45. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  46. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  47. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  48. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  49. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  50. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  51. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  52. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  53. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  54. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  55. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  56. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  57. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  58. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  59. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  60. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  61. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  62. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  63. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  64. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  65. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  66. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  67. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  68. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  69. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  70. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  71. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  72. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  73. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  74. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  75. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  76. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  77. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  78. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  79. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  80. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  81. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  82. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  83. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  84. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  85. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  86. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  87. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  88. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  89. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  90. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  91. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  92. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  93. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  94. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  95. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  96. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  97. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  98. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  99. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  100. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  101. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  102. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  103. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  104. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  105. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  106. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  107. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  108. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  109. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  110. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  111. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  112. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  113. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  114. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  115. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
@@ -0,0 +1,1633 @@
1
+ /**
2
+ * Overview Tab Controller
3
+ *
4
+ * Handles Overview tab data loading, card rendering, and table management.
5
+ * Extracted from index.astro for task-22.5 (slim to <300 lines)
6
+ */
7
+
8
+ import { formatNumber, formatBytes, formatCurrency } from './formatters';
9
+ import { getSSRConfig } from './constants';
10
+ import { CF_PRICING } from '../../../lib/cloudflare/costs';
11
+ import { fetchDailyData, syncFromURL, initSubscriptions } from '../state/usageActions';
12
+
13
+ // ========== Types ==========
14
+
15
+ export interface UsageData {
16
+ workers: WorkerData[];
17
+ d1: D1Data[];
18
+ kv: KVData[];
19
+ r2: R2Data[];
20
+ vectorize: VectorizeData[];
21
+ aiGateway: AIGatewayData[];
22
+ pages: PagesData[];
23
+ durableObjects: DurableObjectsData;
24
+ }
25
+
26
+ export interface WorkerData {
27
+ scriptName: string;
28
+ requests: number;
29
+ errors: number;
30
+ cpuTime?: number;
31
+ wallTime?: number;
32
+ }
33
+
34
+ export interface D1Data {
35
+ databaseId: string;
36
+ databaseName: string;
37
+ rowsRead: number;
38
+ rowsWritten: number;
39
+ }
40
+
41
+ export interface KVData {
42
+ namespaceId: string;
43
+ namespaceName: string;
44
+ reads: number;
45
+ writes: number;
46
+ deletes: number;
47
+ lists: number;
48
+ }
49
+
50
+ export interface R2Data {
51
+ bucketName: string;
52
+ storageBytes: number;
53
+ readOps: number;
54
+ writeOps: number;
55
+ }
56
+
57
+ export interface VectorizeData {
58
+ id: string;
59
+ name: string;
60
+ vectorCount: number;
61
+ dimensions: number;
62
+ }
63
+
64
+ export interface AIGatewayData {
65
+ name: string;
66
+ requests: number;
67
+ tokens: number;
68
+ }
69
+
70
+ export interface PagesData {
71
+ projectName: string;
72
+ productionDeployments: number;
73
+ previewDeployments: number;
74
+ }
75
+
76
+ export interface DurableObjectsData {
77
+ requests: number;
78
+ wallTime: number;
79
+ }
80
+
81
+ export interface CostBreakdown {
82
+ workers: number;
83
+ d1: number;
84
+ kv: number;
85
+ r2: number;
86
+ vectorize: number;
87
+ aiGateway: number;
88
+ pages: number;
89
+ durableObjects: number;
90
+ total: number;
91
+ }
92
+
93
+ export interface ThresholdWarning {
94
+ level: 'warning' | 'high' | 'critical';
95
+ resource: string;
96
+ resourceName: string;
97
+ metric: string;
98
+ current: number;
99
+ limit: number;
100
+ percentage: number;
101
+ }
102
+
103
+ export interface ThresholdData {
104
+ warnings: ThresholdWarning[];
105
+ overallLevel: 'healthy' | 'warning' | 'high' | 'critical';
106
+ }
107
+
108
+ export interface UnifiedResource {
109
+ id: string;
110
+ name: string;
111
+ type: string;
112
+ project: string;
113
+ usage: {
114
+ value: number;
115
+ formatted: string;
116
+ unit: string;
117
+ };
118
+ costCurrent: number;
119
+ costPrior: number;
120
+ costDelta: number;
121
+ costDeltaPct: number | 'NEW' | null;
122
+ status: 'healthy' | 'warning' | 'high' | 'critical';
123
+ }
124
+
125
+ // ========== Constants ==========
126
+
127
+ const TYPE_ICONS: Record<string, string> = {
128
+ worker: '\u2699\uFE0F',
129
+ d1: '\uD83D\uDDC3\uFE0F',
130
+ kv: '\uD83D\uDD11',
131
+ r2: '\uD83D\uDEE2\uFE0F',
132
+ vectorize: '\uD83E\uDDE0',
133
+ pages: '\uD83D\uDCC4',
134
+ do: '\uD83D\uDD17',
135
+ 'ai-gateway': '\uD83E\uDD16',
136
+ };
137
+
138
+ const TYPE_LABELS: Record<string, string> = {
139
+ worker: 'Worker',
140
+ d1: 'D1 Database',
141
+ kv: 'KV Namespace',
142
+ r2: 'R2 Bucket',
143
+ vectorize: 'Vectorize',
144
+ pages: 'Pages',
145
+ do: 'Durable Object',
146
+ 'ai-gateway': 'AI Gateway',
147
+ };
148
+
149
+ const STATUS_COLOURS: Record<string, string> = {
150
+ healthy: '#10B981',
151
+ warning: '#F59E0B',
152
+ high: '#F97316',
153
+ critical: '#EF4444',
154
+ };
155
+
156
+ // ========== Status Helpers ==========
157
+
158
+ export function getStatusFromPercentage(
159
+ percentage: number
160
+ ): 'healthy' | 'warning' | 'high' | 'critical' {
161
+ if (percentage >= 90) return 'critical';
162
+ if (percentage >= 75) return 'high';
163
+ if (percentage >= 50) return 'warning';
164
+ return 'healthy';
165
+ }
166
+
167
+ function getStatusFromErrorRate(
168
+ errors: number,
169
+ requests: number
170
+ ): 'healthy' | 'warning' | 'high' | 'critical' {
171
+ if (requests === 0) return 'healthy';
172
+ const errorRate = (errors / requests) * 100;
173
+ if (errorRate >= 10) return 'critical';
174
+ if (errorRate >= 5) return 'high';
175
+ if (errorRate >= 1) return 'warning';
176
+ return 'healthy';
177
+ }
178
+
179
+ // ========== Project Identification ==========
180
+
181
+ export function identifyProject(resourceName: string): string {
182
+ const name = resourceName.toLowerCase();
183
+ if (
184
+ name.includes('brand-copilot') ||
185
+ name.includes('brand_copilot') ||
186
+ name.includes('brandcopilot')
187
+ ) {
188
+ return 'brand-copilot';
189
+ }
190
+ if (name.includes('scout')) {
191
+ return 'scout';
192
+ }
193
+ if (name.includes('platform') || name.includes('admin')) {
194
+ return 'platform';
195
+ }
196
+ return resourceName;
197
+ }
198
+
199
+ // ========== Workers Breakdown ==========
200
+
201
+ /**
202
+ * Project breakdown data for the WorkersBreakdownTable component
203
+ */
204
+ interface ProjectBreakdown {
205
+ project: string;
206
+ totalRequests: number;
207
+ totalErrors: number;
208
+ workerCount: number;
209
+ workers: Array<{
210
+ scriptName: string;
211
+ requests: number;
212
+ errors: number;
213
+ cpuTime: number;
214
+ }>;
215
+ }
216
+
217
+ /**
218
+ * Group workers by project for the breakdown table.
219
+ * Uses identifyProject() to categorize workers.
220
+ */
221
+ export function buildWorkersBreakdown(workers: WorkerData[]): ProjectBreakdown[] {
222
+ if (!workers || workers.length === 0) return [];
223
+
224
+ // Group workers by project
225
+ const projectMap = new Map<string, ProjectBreakdown>();
226
+
227
+ for (const worker of workers) {
228
+ const project = identifyProject(worker.scriptName);
229
+
230
+ if (!projectMap.has(project)) {
231
+ projectMap.set(project, {
232
+ project,
233
+ totalRequests: 0,
234
+ totalErrors: 0,
235
+ workerCount: 0,
236
+ workers: [],
237
+ });
238
+ }
239
+
240
+ const breakdown = projectMap.get(project)!;
241
+ breakdown.totalRequests += worker.requests || 0;
242
+ breakdown.totalErrors += worker.errors || 0;
243
+ breakdown.workerCount += 1;
244
+ breakdown.workers.push({
245
+ scriptName: worker.scriptName,
246
+ requests: worker.requests || 0,
247
+ errors: worker.errors || 0,
248
+ cpuTime: worker.cpuTime || 0,
249
+ });
250
+ }
251
+
252
+ // Convert to array and sort by total requests (descending)
253
+ const breakdowns = Array.from(projectMap.values());
254
+ breakdowns.sort((a, b) => b.totalRequests - a.totalRequests);
255
+
256
+ // Sort workers within each project by requests (descending)
257
+ for (const breakdown of breakdowns) {
258
+ breakdown.workers.sort((a, b) => b.requests - a.requests);
259
+ }
260
+
261
+ return breakdowns;
262
+ }
263
+
264
+ /**
265
+ * Update the WorkersBreakdownTable component with current data.
266
+ */
267
+ export function updateWorkersBreakdownTable(workers: WorkerData[]): void {
268
+ const breakdown = buildWorkersBreakdown(workers);
269
+
270
+ if (typeof window !== 'undefined' && window.updateWorkersBreakdown) {
271
+ window.updateWorkersBreakdown(breakdown);
272
+ }
273
+ }
274
+
275
+ // ========== Format Helpers ==========
276
+
277
+ function formatDeltaPct(deltaPct: number | 'NEW' | null): string {
278
+ if (deltaPct === 'NEW') return 'NEW';
279
+ if (deltaPct === null) return '-';
280
+ const sign = deltaPct >= 0 ? '+' : '';
281
+ return sign + deltaPct.toFixed(1) + '%';
282
+ }
283
+
284
+ function getDeltaClass(deltaPct: number | 'NEW' | null): string {
285
+ if (deltaPct === 'NEW') return 'delta-new';
286
+ if (deltaPct === null) return 'delta-neutral';
287
+ if (deltaPct > 5) return 'delta-up';
288
+ if (deltaPct < -5) return 'delta-down';
289
+ return 'delta-neutral';
290
+ }
291
+
292
+ /**
293
+ * Apply comparison data to resources.
294
+ * Updates costPrior, costDelta, and costDeltaPct for each resource.
295
+ */
296
+ function applyComparisonData(
297
+ resources: UnifiedResource[],
298
+ priorResources: UnifiedResource[]
299
+ ): UnifiedResource[] {
300
+ const priorMap = new Map(priorResources.map((r) => [r.id, r]));
301
+
302
+ return resources.map((resource) => {
303
+ const prior = priorMap.get(resource.id);
304
+
305
+ if (!prior) {
306
+ // New resource - mark as NEW
307
+ return {
308
+ ...resource,
309
+ costDeltaPct: 'NEW' as const,
310
+ };
311
+ }
312
+
313
+ const costDelta = resource.costCurrent - prior.costCurrent;
314
+ // Use $0.01 threshold to avoid extreme percentages from near-zero baselines
315
+ // Cap at 999% for display sanity
316
+ const costDeltaPct =
317
+ prior.costCurrent >= 0.01
318
+ ? Math.min((costDelta / prior.costCurrent) * 100, 999)
319
+ : resource.costCurrent > 0
320
+ ? 'NEW'
321
+ : 0;
322
+
323
+ return {
324
+ ...resource,
325
+ costPrior: prior.costCurrent,
326
+ costDelta,
327
+ costDeltaPct:
328
+ typeof costDeltaPct === 'number' ? Math.round(costDeltaPct * 10) / 10 : costDeltaPct,
329
+ };
330
+ });
331
+ }
332
+
333
+ // ========== Transform Functions ==========
334
+
335
+ export function transformToUnifiedResources(
336
+ data: UsageData,
337
+ costs: CostBreakdown,
338
+ projectMapping: (name: string) => string
339
+ ): UnifiedResource[] {
340
+ const resources: UnifiedResource[] = [];
341
+
342
+ // Transform Workers - calculate per-resource cost based on actual usage
343
+ if (data.workers && data.workers.length > 0) {
344
+ data.workers.forEach((worker) => {
345
+ // Workers pricing: $0.30 per million requests + $0.02 per million CPU ms
346
+ const requestCost =
347
+ ((worker.requests || 0) / 1_000_000) * CF_PRICING.workers.requestsPerMillion;
348
+ const cpuCost = ((worker.cpuTime || 0) / 1_000_000) * CF_PRICING.workers.cpuMsPerMillion;
349
+ const costPerWorker = requestCost + cpuCost;
350
+ resources.push({
351
+ id: `worker-${worker.scriptName}`,
352
+ name: worker.scriptName,
353
+ type: 'worker',
354
+ project: projectMapping(worker.scriptName),
355
+ usage: {
356
+ value: worker.requests || 0,
357
+ unit: 'requests',
358
+ formatted: formatNumber(worker.requests || 0),
359
+ },
360
+ costCurrent: costPerWorker,
361
+ costPrior: 0,
362
+ costDelta: 0,
363
+ costDeltaPct: null,
364
+ status: getStatusFromErrorRate(worker.errors || 0, worker.requests || 0),
365
+ });
366
+ });
367
+ }
368
+
369
+ // Transform D1 - split into separate rows for reads and writes
370
+ if (data.d1 && data.d1.length > 0) {
371
+ data.d1.forEach((db) => {
372
+ const dbName = db.databaseName || db.databaseId;
373
+ const rowsRead = db.rowsRead || 0;
374
+ const rowsWritten = db.rowsWritten || 0;
375
+
376
+ // D1 pricing: $0.001 per billion rows read
377
+ const rowsReadCost = (rowsRead / 1_000_000_000) * CF_PRICING.d1.rowsReadPerBillion;
378
+ // D1 pricing: $1.00 per million rows written
379
+ const rowsWrittenCost = (rowsWritten / 1_000_000) * CF_PRICING.d1.rowsWrittenPerMillion;
380
+
381
+ // Row for D1 reads
382
+ if (rowsRead > 0) {
383
+ resources.push({
384
+ id: `d1-${db.databaseId}-reads`,
385
+ name: `${dbName} (reads)`,
386
+ type: 'd1',
387
+ project: projectMapping(dbName),
388
+ usage: {
389
+ value: rowsRead,
390
+ unit: 'rows read',
391
+ formatted: formatNumber(rowsRead),
392
+ },
393
+ costCurrent: rowsReadCost,
394
+ costPrior: 0,
395
+ costDelta: 0,
396
+ costDeltaPct: null,
397
+ status: 'healthy',
398
+ });
399
+ }
400
+
401
+ // Row for D1 writes
402
+ if (rowsWritten > 0) {
403
+ resources.push({
404
+ id: `d1-${db.databaseId}-writes`,
405
+ name: `${dbName} (writes)`,
406
+ type: 'd1',
407
+ project: projectMapping(dbName),
408
+ usage: {
409
+ value: rowsWritten,
410
+ unit: 'rows written',
411
+ formatted: formatNumber(rowsWritten),
412
+ },
413
+ costCurrent: rowsWrittenCost,
414
+ costPrior: 0,
415
+ costDelta: 0,
416
+ costDeltaPct: null,
417
+ status: 'healthy',
418
+ });
419
+ }
420
+
421
+ // If no reads or writes, still show the database with zero
422
+ if (rowsRead === 0 && rowsWritten === 0) {
423
+ resources.push({
424
+ id: `d1-${db.databaseId}`,
425
+ name: dbName,
426
+ type: 'd1',
427
+ project: projectMapping(dbName),
428
+ usage: {
429
+ value: 0,
430
+ unit: 'rows',
431
+ formatted: '0',
432
+ },
433
+ costCurrent: 0,
434
+ costPrior: 0,
435
+ costDelta: 0,
436
+ costDeltaPct: null,
437
+ status: 'healthy',
438
+ });
439
+ }
440
+ });
441
+ }
442
+
443
+ // Transform KV - calculate per-resource cost based on actual usage
444
+ if (data.kv && data.kv.length > 0) {
445
+ data.kv.forEach((ns) => {
446
+ const totalOps = (ns.reads || 0) + (ns.writes || 0) + (ns.deletes || 0) + (ns.lists || 0);
447
+ // KV pricing: $0.50/M reads + $5.00/M writes + $5.00/M deletes + $5.00/M lists
448
+ const costPerKv =
449
+ ((ns.reads || 0) / 1_000_000) * CF_PRICING.kv.readsPerMillion +
450
+ ((ns.writes || 0) / 1_000_000) * CF_PRICING.kv.writesPerMillion +
451
+ ((ns.deletes || 0) / 1_000_000) * CF_PRICING.kv.deletesPerMillion +
452
+ ((ns.lists || 0) / 1_000_000) * CF_PRICING.kv.listsPerMillion;
453
+ resources.push({
454
+ id: `kv-${ns.namespaceId}`,
455
+ name: ns.namespaceName || ns.namespaceId,
456
+ type: 'kv',
457
+ project: projectMapping(ns.namespaceName || ns.namespaceId),
458
+ usage: {
459
+ value: totalOps,
460
+ unit: 'operations',
461
+ formatted: formatNumber(totalOps),
462
+ },
463
+ costCurrent: costPerKv,
464
+ costPrior: 0,
465
+ costDelta: 0,
466
+ costDeltaPct: null,
467
+ status: 'healthy',
468
+ });
469
+ });
470
+ }
471
+
472
+ // Transform R2 - calculate per-resource cost based on actual usage
473
+ if (data.r2 && data.r2.length > 0) {
474
+ data.r2.forEach((bucket) => {
475
+ // R2 pricing: $0.015/GB storage + $4.50/M Class A ops (writes) + $0.36/M Class B ops (reads)
476
+ // Note: Cloudflare uses decimal GB (1 GB = 1,000,000,000 bytes) for billing
477
+ const costPerBucket =
478
+ ((bucket.storageBytes || 0) / 1_000_000_000) * CF_PRICING.r2.storagePerGbMonth +
479
+ ((bucket.writeOps || 0) / 1_000_000) * CF_PRICING.r2.classAPerMillion +
480
+ ((bucket.readOps || 0) / 1_000_000) * CF_PRICING.r2.classBPerMillion;
481
+ resources.push({
482
+ id: `r2-${bucket.bucketName}`,
483
+ name: bucket.bucketName,
484
+ type: 'r2',
485
+ project: projectMapping(bucket.bucketName),
486
+ usage: {
487
+ value: bucket.storageBytes || 0,
488
+ unit: 'storage',
489
+ formatted: formatBytes(bucket.storageBytes || 0),
490
+ },
491
+ costCurrent: costPerBucket,
492
+ costPrior: 0,
493
+ costDelta: 0,
494
+ costDeltaPct: null,
495
+ status: 'healthy',
496
+ });
497
+ });
498
+ }
499
+
500
+ // Transform Vectorize - calculate per-resource cost based on actual usage
501
+ if (data.vectorize && data.vectorize.length > 0) {
502
+ data.vectorize.forEach((index) => {
503
+ // Vectorize pricing: $0.01 per million stored dimensions
504
+ const storedDimensions = (index.vectorCount || 0) * (index.dimensions || 0);
505
+ const costPerIndex =
506
+ (storedDimensions / 1_000_000) * CF_PRICING.vectorize.storedDimensionsPerMillion;
507
+ resources.push({
508
+ id: `vectorize-${index.id}`,
509
+ name: index.name || index.id,
510
+ type: 'vectorize',
511
+ project: projectMapping(index.name || index.id),
512
+ usage: {
513
+ value: index.vectorCount || 0,
514
+ unit: 'vectors',
515
+ formatted: formatNumber(index.vectorCount || 0),
516
+ },
517
+ costCurrent: costPerIndex,
518
+ costPrior: 0,
519
+ costDelta: 0,
520
+ costDeltaPct: null,
521
+ status: 'healthy',
522
+ });
523
+ });
524
+ }
525
+
526
+ // Transform AI Gateway
527
+ if (data.aiGateway && data.aiGateway.length > 0) {
528
+ const costPerGateway = (costs.aiGateway || 0) / data.aiGateway.length;
529
+ data.aiGateway.forEach((gateway) => {
530
+ resources.push({
531
+ id: `aigateway-${gateway.name}`,
532
+ name: gateway.name,
533
+ type: 'ai-gateway',
534
+ project: projectMapping(gateway.name),
535
+ usage: {
536
+ value: gateway.requests || 0,
537
+ unit: 'requests',
538
+ formatted: formatNumber(gateway.requests || 0),
539
+ },
540
+ costCurrent: costPerGateway,
541
+ costPrior: 0,
542
+ costDelta: 0,
543
+ costDeltaPct: null,
544
+ status: 'healthy',
545
+ });
546
+ });
547
+ }
548
+
549
+ // Transform Pages - calculate per-resource cost based on actual usage
550
+ if (data.pages && data.pages.length > 0) {
551
+ data.pages.forEach((page) => {
552
+ const totalDeployments = (page.productionDeployments || 0) + (page.previewDeployments || 0);
553
+ // Pages pricing: $0.15 per build after 500 free
554
+ const costPerPage = totalDeployments * CF_PRICING.pages.buildCost;
555
+ resources.push({
556
+ id: `pages-${page.projectName}`,
557
+ name: page.projectName,
558
+ type: 'pages',
559
+ project: projectMapping(page.projectName),
560
+ usage: {
561
+ value: totalDeployments,
562
+ unit: 'deployments',
563
+ formatted: formatNumber(totalDeployments),
564
+ },
565
+ costCurrent: costPerPage,
566
+ costPrior: 0,
567
+ costDelta: 0,
568
+ costDeltaPct: null,
569
+ status: 'healthy',
570
+ });
571
+ });
572
+ }
573
+
574
+ // Transform Durable Objects (aggregate)
575
+ if (data.durableObjects && data.durableObjects.requests > 0) {
576
+ resources.push({
577
+ id: 'do-aggregate',
578
+ name: 'Durable Objects (All)',
579
+ type: 'do',
580
+ project: 'platform',
581
+ usage: {
582
+ value: data.durableObjects.requests || 0,
583
+ unit: 'requests',
584
+ formatted: formatNumber(data.durableObjects.requests || 0),
585
+ },
586
+ costCurrent: costs.durableObjects || 0,
587
+ costPrior: 0,
588
+ costDelta: 0,
589
+ costDeltaPct: null,
590
+ status: 'healthy',
591
+ });
592
+ }
593
+
594
+ return resources;
595
+ }
596
+
597
+ // ========== DOM Builders ==========
598
+
599
+ export function buildUsageCard(
600
+ label: string,
601
+ icon: string,
602
+ primaryValue: string,
603
+ primaryLabel: string,
604
+ secondaryItems?: Array<{ label: string; value: string }>
605
+ ): HTMLElement {
606
+ const card = document.createElement('div');
607
+ card.className = 'usage-card';
608
+
609
+ const header = document.createElement('div');
610
+ header.className = 'usage-card-header';
611
+
612
+ const iconSpan = document.createElement('span');
613
+ iconSpan.className = 'usage-card-icon';
614
+ iconSpan.textContent = icon;
615
+
616
+ const labelSpan = document.createElement('span');
617
+ labelSpan.className = 'usage-card-label';
618
+ labelSpan.textContent = label;
619
+
620
+ header.appendChild(iconSpan);
621
+ header.appendChild(labelSpan);
622
+
623
+ const content = document.createElement('div');
624
+ content.className = 'usage-card-content';
625
+
626
+ const primary = document.createElement('div');
627
+ primary.className = 'usage-card-primary';
628
+
629
+ const valueSpan = document.createElement('span');
630
+ valueSpan.className = 'usage-card-value';
631
+ valueSpan.textContent = primaryValue;
632
+
633
+ const labelText = document.createElement('span');
634
+ labelText.className = 'usage-card-primary-label';
635
+ labelText.textContent = primaryLabel;
636
+
637
+ primary.appendChild(valueSpan);
638
+ primary.appendChild(labelText);
639
+ content.appendChild(primary);
640
+
641
+ if (secondaryItems && secondaryItems.length > 0) {
642
+ const secondary = document.createElement('div');
643
+ secondary.className = 'usage-card-secondary';
644
+
645
+ secondaryItems.forEach((item) => {
646
+ const itemDiv = document.createElement('div');
647
+ itemDiv.className = 'usage-card-secondary-item';
648
+
649
+ const itemLabel = document.createElement('span');
650
+ itemLabel.className = 'secondary-label';
651
+ itemLabel.textContent = item.label;
652
+
653
+ const itemValue = document.createElement('span');
654
+ itemValue.className = 'secondary-value';
655
+ itemValue.textContent = item.value;
656
+
657
+ itemDiv.appendChild(itemLabel);
658
+ itemDiv.appendChild(itemValue);
659
+ secondary.appendChild(itemDiv);
660
+ });
661
+
662
+ content.appendChild(secondary);
663
+ }
664
+
665
+ card.appendChild(header);
666
+ card.appendChild(content);
667
+ return card;
668
+ }
669
+
670
+ export function buildSparklineCard(config: {
671
+ label: string;
672
+ icon: string;
673
+ primaryValue: string;
674
+ primaryLabel: string;
675
+ sparklineData?: number[];
676
+ trend: 'up' | 'down' | 'stable';
677
+ percentChange?: number;
678
+ status: 'healthy' | 'warning' | 'high' | 'critical';
679
+ secondaryValue?: string;
680
+ secondaryLabel?: string;
681
+ comparisonText?: string;
682
+ cardId: string;
683
+ }): HTMLElement {
684
+ const {
685
+ label,
686
+ icon,
687
+ primaryValue,
688
+ primaryLabel,
689
+ sparklineData,
690
+ trend,
691
+ percentChange,
692
+ status,
693
+ secondaryValue,
694
+ secondaryLabel,
695
+ comparisonText,
696
+ cardId,
697
+ } = config;
698
+
699
+ const card = document.createElement('div');
700
+ card.className = 'sparkline-card';
701
+ card.setAttribute('role', 'region');
702
+ card.setAttribute('aria-label', label);
703
+ card.setAttribute('data-card-id', cardId);
704
+
705
+ const statusColour = STATUS_COLOURS[status] || STATUS_COLOURS.healthy;
706
+ const isErrorMetric = label.toLowerCase().includes('error');
707
+ const trendColour =
708
+ trend === 'stable'
709
+ ? '#6B7280'
710
+ : trend === 'up'
711
+ ? isErrorMetric
712
+ ? '#EF4444'
713
+ : '#10B981'
714
+ : isErrorMetric
715
+ ? '#10B981'
716
+ : '#EF4444';
717
+ const trendArrow = trend === 'up' ? '\u2191' : trend === 'down' ? '\u2193' : '\u2192';
718
+
719
+ // Header
720
+ const header = document.createElement('div');
721
+ header.className = 'card-header';
722
+
723
+ const headerLeft = document.createElement('div');
724
+ headerLeft.className = 'header-left';
725
+
726
+ const statusDot = document.createElement('span');
727
+ statusDot.className = 'status-dot';
728
+ statusDot.style.backgroundColor = statusColour;
729
+ statusDot.title = 'Status: ' + status;
730
+
731
+ const cardIcon = document.createElement('span');
732
+ cardIcon.className = 'card-icon';
733
+ cardIcon.textContent = icon;
734
+
735
+ const cardLabel = document.createElement('span');
736
+ cardLabel.className = 'card-label';
737
+ cardLabel.textContent = label;
738
+
739
+ headerLeft.appendChild(statusDot);
740
+ headerLeft.appendChild(cardIcon);
741
+ headerLeft.appendChild(cardLabel);
742
+ header.appendChild(headerLeft);
743
+
744
+ if (trend !== 'stable') {
745
+ const trendBadge = document.createElement('div');
746
+ trendBadge.className = 'trend-badge';
747
+ trendBadge.style.color = trendColour;
748
+ trendBadge.style.backgroundColor = trendColour + '15';
749
+
750
+ const arrow = document.createElement('span');
751
+ arrow.className = 'trend-arrow';
752
+ arrow.textContent = trendArrow;
753
+
754
+ const value = document.createElement('span');
755
+ value.className = 'trend-value';
756
+ value.textContent = Math.abs(percentChange || 0).toFixed(1) + '%';
757
+
758
+ trendBadge.appendChild(arrow);
759
+ trendBadge.appendChild(value);
760
+ header.appendChild(trendBadge);
761
+ }
762
+
763
+ card.appendChild(header);
764
+
765
+ // Primary
766
+ const primary = document.createElement('div');
767
+ primary.className = 'card-primary';
768
+
769
+ const primaryValueSpan = document.createElement('span');
770
+ primaryValueSpan.className = 'primary-value';
771
+ primaryValueSpan.textContent = primaryValue;
772
+
773
+ const primaryLabelSpan = document.createElement('span');
774
+ primaryLabelSpan.className = 'primary-label';
775
+ primaryLabelSpan.textContent = primaryLabel;
776
+
777
+ primary.appendChild(primaryValueSpan);
778
+ primary.appendChild(primaryLabelSpan);
779
+ card.appendChild(primary);
780
+
781
+ // Sparkline container
782
+ if (sparklineData && sparklineData.length > 1) {
783
+ const sparklineContainer = document.createElement('div');
784
+ sparklineContainer.className = 'sparkline-container';
785
+
786
+ const canvas = document.createElement('canvas');
787
+ canvas.id = 'sparkline-' + cardId;
788
+ canvas.className = 'sparkline-canvas';
789
+ canvas.setAttribute('aria-label', label + ' trend over time');
790
+
791
+ sparklineContainer.appendChild(canvas);
792
+ card.appendChild(sparklineContainer);
793
+
794
+ setTimeout(() => renderSparkline(canvas.id, sparklineData, trendColour), 0);
795
+ }
796
+
797
+ // Footer
798
+ if (secondaryValue || comparisonText) {
799
+ const footer = document.createElement('div');
800
+ footer.className = 'card-footer';
801
+
802
+ if (secondaryValue) {
803
+ const secondaryMetric = document.createElement('div');
804
+ secondaryMetric.className = 'secondary-metric';
805
+
806
+ const secVal = document.createElement('span');
807
+ secVal.className = 'secondary-value';
808
+ secVal.textContent = secondaryValue;
809
+ secondaryMetric.appendChild(secVal);
810
+
811
+ if (secondaryLabel) {
812
+ const secLabel = document.createElement('span');
813
+ secLabel.className = 'secondary-label';
814
+ secLabel.textContent = secondaryLabel;
815
+ secondaryMetric.appendChild(secLabel);
816
+ }
817
+
818
+ footer.appendChild(secondaryMetric);
819
+ }
820
+
821
+ if (comparisonText) {
822
+ const comparison = document.createElement('div');
823
+ comparison.className = 'comparison-text';
824
+ comparison.textContent = comparisonText;
825
+ footer.appendChild(comparison);
826
+ }
827
+
828
+ card.appendChild(footer);
829
+ }
830
+
831
+ return card;
832
+ }
833
+
834
+ // ========== Chart Rendering ==========
835
+
836
+ async function renderSparkline(canvasId: string, data: number[], colour: string): Promise<void> {
837
+ const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null;
838
+ if (!canvas) return;
839
+
840
+ try {
841
+ const { Chart, registerables } = await import('chart.js');
842
+ Chart.register(...registerables);
843
+
844
+ const ctx = canvas.getContext('2d');
845
+ if (!ctx) return;
846
+
847
+ new Chart(ctx, {
848
+ type: 'line',
849
+ data: {
850
+ labels: data.map((_, i) => String(i)),
851
+ datasets: [
852
+ {
853
+ data: data,
854
+ borderColor: colour,
855
+ backgroundColor: colour + '20',
856
+ fill: true,
857
+ tension: 0.4,
858
+ pointRadius: 0,
859
+ borderWidth: 2,
860
+ },
861
+ ],
862
+ },
863
+ options: {
864
+ responsive: true,
865
+ maintainAspectRatio: false,
866
+ plugins: { legend: { display: false }, tooltip: { enabled: false } },
867
+ scales: { x: { display: false }, y: { display: false } },
868
+ },
869
+ });
870
+ } catch (e) {
871
+ console.error('Failed to render sparkline:', e);
872
+ }
873
+ }
874
+
875
+ export async function buildCostBreakdownCard(
876
+ container: HTMLElement,
877
+ items: Array<{ label: string; amount: number; colour: string }>
878
+ ): Promise<void> {
879
+ container.textContent = '';
880
+
881
+ if (items.length === 0 || items.every((i) => i.amount === 0)) {
882
+ const empty = document.createElement('div');
883
+ empty.className = 'empty-state';
884
+ empty.textContent = 'No cost data available';
885
+ container.appendChild(empty);
886
+ return;
887
+ }
888
+
889
+ const wrapper = document.createElement('div');
890
+ wrapper.className = 'cost-breakdown-wrapper';
891
+
892
+ const chartContent = document.createElement('div');
893
+ chartContent.className = 'cost-breakdown-content';
894
+
895
+ const chartContainer = document.createElement('div');
896
+ chartContainer.className = 'cost-donut-container';
897
+
898
+ const canvas = document.createElement('canvas');
899
+ canvas.id = 'cost-breakdown-chart';
900
+ canvas.className = 'cost-donut-chart';
901
+ chartContainer.appendChild(canvas);
902
+
903
+ chartContent.appendChild(chartContainer);
904
+
905
+ // Legend
906
+ const legend = document.createElement('div');
907
+ legend.className = 'cost-legend';
908
+
909
+ items.forEach((item) => {
910
+ if (item.amount === 0) return;
911
+ const legendItem = document.createElement('div');
912
+ legendItem.className = 'legend-item';
913
+
914
+ const colour = document.createElement('span');
915
+ colour.className = 'legend-colour';
916
+ colour.style.backgroundColor = item.colour;
917
+
918
+ const labelSpan = document.createElement('span');
919
+ labelSpan.className = 'legend-label';
920
+ labelSpan.textContent = item.label;
921
+
922
+ const valueSpan = document.createElement('span');
923
+ valueSpan.className = 'legend-value';
924
+ valueSpan.textContent = '$' + item.amount.toFixed(2);
925
+
926
+ legendItem.appendChild(colour);
927
+ legendItem.appendChild(labelSpan);
928
+ legendItem.appendChild(valueSpan);
929
+ legend.appendChild(legendItem);
930
+ });
931
+
932
+ chartContent.appendChild(legend);
933
+ wrapper.appendChild(chartContent);
934
+ container.appendChild(wrapper);
935
+
936
+ // Render chart
937
+ try {
938
+ const { Chart, registerables } = await import('chart.js');
939
+ Chart.register(...registerables);
940
+
941
+ const ctx = canvas.getContext('2d');
942
+ if (!ctx) return;
943
+
944
+ new Chart(ctx, {
945
+ type: 'doughnut',
946
+ data: {
947
+ labels: items.map((i) => i.label),
948
+ datasets: [
949
+ {
950
+ data: items.map((i) => i.amount),
951
+ backgroundColor: items.map((i) => i.colour),
952
+ borderColor: 'transparent',
953
+ borderWidth: 0,
954
+ hoverOffset: 4,
955
+ },
956
+ ],
957
+ },
958
+ options: {
959
+ responsive: true,
960
+ maintainAspectRatio: true,
961
+ cutout: '65%',
962
+ plugins: {
963
+ legend: { display: false },
964
+ tooltip: { enabled: true, backgroundColor: '#1f2937' },
965
+ },
966
+ },
967
+ });
968
+ } catch (e) {
969
+ console.error('Failed to render donut chart:', e);
970
+ }
971
+ }
972
+
973
+ // ========== Threshold Banner ==========
974
+
975
+ export function buildCompactThresholdBanner(thresholds: ThresholdData): HTMLElement | null {
976
+ const alertWarnings = thresholds.warnings.filter(
977
+ (w) => w.level === 'warning' || w.level === 'high' || w.level === 'critical'
978
+ );
979
+ if (alertWarnings.length === 0) return null;
980
+
981
+ const criticalCount = alertWarnings.filter((w) => w.level === 'critical').length;
982
+ const highCount = alertWarnings.filter((w) => w.level === 'high').length;
983
+ const warningCount = alertWarnings.filter((w) => w.level === 'warning').length;
984
+
985
+ const bannerStyle =
986
+ thresholds.overallLevel === 'critical'
987
+ ? 'banner-critical'
988
+ : thresholds.overallLevel === 'high'
989
+ ? 'banner-high'
990
+ : 'banner-warning';
991
+
992
+ const banner = document.createElement('div');
993
+ banner.className = 'compact-threshold-banner ' + bannerStyle;
994
+ banner.setAttribute('role', 'alert');
995
+
996
+ const summary = document.createElement('div');
997
+ summary.className = 'banner-summary';
998
+
999
+ const badges = document.createElement('div');
1000
+ badges.className = 'severity-badges';
1001
+
1002
+ if (criticalCount > 0) {
1003
+ badges.appendChild(createSeverityBadge(criticalCount, 'critical', 'Critical'));
1004
+ }
1005
+ if (highCount > 0) {
1006
+ badges.appendChild(createSeverityBadge(highCount, 'high', 'High'));
1007
+ }
1008
+ if (warningCount > 0) {
1009
+ badges.appendChild(createSeverityBadge(warningCount, 'warning', 'Warning'));
1010
+ }
1011
+
1012
+ summary.appendChild(badges);
1013
+
1014
+ const bars = document.createElement('div');
1015
+ bars.className = 'threshold-bars';
1016
+
1017
+ alertWarnings.slice(0, 4).forEach((w) => {
1018
+ const container = document.createElement('div');
1019
+ container.className = 'mini-bar-container';
1020
+ container.title = w.resourceName + ': ' + w.percentage.toFixed(0) + '%';
1021
+
1022
+ const bar = document.createElement('div');
1023
+ bar.className = 'mini-bar bar-' + w.level;
1024
+ bar.style.width = Math.min(w.percentage, 100) + '%';
1025
+
1026
+ container.appendChild(bar);
1027
+ bars.appendChild(container);
1028
+ });
1029
+
1030
+ if (alertWarnings.length > 4) {
1031
+ const more = document.createElement('span');
1032
+ more.className = 'more-count';
1033
+ more.textContent = '+' + (alertWarnings.length - 4);
1034
+ bars.appendChild(more);
1035
+ }
1036
+
1037
+ summary.appendChild(bars);
1038
+
1039
+ const expandBtn = document.createElement('button');
1040
+ expandBtn.className = 'expand-button';
1041
+ expandBtn.setAttribute('aria-expanded', 'false');
1042
+
1043
+ const expandIcon = document.createElement('span');
1044
+ expandIcon.className = 'expand-icon';
1045
+ expandIcon.textContent = '\u25BC';
1046
+ expandBtn.appendChild(expandIcon);
1047
+
1048
+ summary.appendChild(expandBtn);
1049
+ banner.appendChild(summary);
1050
+
1051
+ // Details section
1052
+ const details = document.createElement('div');
1053
+ details.className = 'banner-details';
1054
+ details.hidden = true;
1055
+
1056
+ const grid = document.createElement('div');
1057
+ grid.className = 'details-grid';
1058
+
1059
+ alertWarnings.forEach((w) => {
1060
+ grid.appendChild(createThresholdDetailItem(w));
1061
+ });
1062
+
1063
+ details.appendChild(grid);
1064
+ banner.appendChild(details);
1065
+
1066
+ expandBtn.addEventListener('click', () => {
1067
+ const isExpanded = expandBtn.getAttribute('aria-expanded') === 'true';
1068
+ expandBtn.setAttribute('aria-expanded', String(!isExpanded));
1069
+ details.hidden = isExpanded;
1070
+ });
1071
+
1072
+ return banner;
1073
+ }
1074
+
1075
+ function createSeverityBadge(count: number, level: string, label: string): HTMLElement {
1076
+ const badge = document.createElement('span');
1077
+ badge.className = 'severity-badge badge-' + level;
1078
+
1079
+ const countSpan = document.createElement('span');
1080
+ countSpan.className = 'badge-count';
1081
+ countSpan.textContent = String(count);
1082
+
1083
+ const labelSpan = document.createElement('span');
1084
+ labelSpan.className = 'badge-label';
1085
+ labelSpan.textContent = label;
1086
+
1087
+ badge.appendChild(countSpan);
1088
+ badge.appendChild(labelSpan);
1089
+ return badge;
1090
+ }
1091
+
1092
+ function createThresholdDetailItem(w: ThresholdWarning): HTMLElement {
1093
+ const item = document.createElement('div');
1094
+ item.className = 'detail-item detail-' + w.level;
1095
+
1096
+ const header = document.createElement('div');
1097
+ header.className = 'detail-header';
1098
+
1099
+ const dot = document.createElement('span');
1100
+ dot.className = 'detail-dot dot-' + w.level;
1101
+
1102
+ const name = document.createElement('span');
1103
+ name.className = 'detail-name';
1104
+ name.textContent = w.resourceName;
1105
+
1106
+ const level = document.createElement('span');
1107
+ level.className = 'detail-level level-' + w.level;
1108
+ level.textContent = w.level.toUpperCase();
1109
+
1110
+ header.appendChild(dot);
1111
+ header.appendChild(name);
1112
+ header.appendChild(level);
1113
+ item.appendChild(header);
1114
+
1115
+ const metric = document.createElement('div');
1116
+ metric.className = 'detail-metric';
1117
+ metric.textContent = w.metric;
1118
+ item.appendChild(metric);
1119
+
1120
+ const progress = document.createElement('div');
1121
+ progress.className = 'detail-progress';
1122
+
1123
+ const barDiv = document.createElement('div');
1124
+ barDiv.className = 'detail-bar';
1125
+
1126
+ const fill = document.createElement('div');
1127
+ fill.className = 'detail-fill fill-' + w.level;
1128
+ fill.style.width = Math.min(w.percentage, 100) + '%';
1129
+ barDiv.appendChild(fill);
1130
+
1131
+ const values = document.createElement('span');
1132
+ values.className = 'detail-values';
1133
+ values.textContent = formatNumber(w.current) + ' / ' + formatNumber(w.limit);
1134
+
1135
+ const pct = document.createElement('span');
1136
+ pct.className = 'detail-percent';
1137
+ pct.textContent = w.percentage.toFixed(0) + '%';
1138
+
1139
+ progress.appendChild(barDiv);
1140
+ progress.appendChild(values);
1141
+ progress.appendChild(pct);
1142
+ item.appendChild(progress);
1143
+
1144
+ return item;
1145
+ }
1146
+
1147
+ // ========== Resource Table Rendering ==========
1148
+
1149
+ export function renderUnifiedResourceTable(resources: UnifiedResource[]): void {
1150
+ const tbody = document.getElementById('unified-resource-table-tbody');
1151
+ if (!tbody) {
1152
+ console.warn('Could not find unified-resource-table-tbody');
1153
+ return;
1154
+ }
1155
+
1156
+ tbody.textContent = '';
1157
+
1158
+ if (resources.length === 0) {
1159
+ const emptyRow = document.createElement('tr');
1160
+ emptyRow.className = 'empty-row';
1161
+
1162
+ const emptyCell = document.createElement('td');
1163
+ emptyCell.colSpan = 7;
1164
+ emptyCell.className = 'empty-cell';
1165
+
1166
+ const emptyState = document.createElement('div');
1167
+ emptyState.className = 'empty-state';
1168
+
1169
+ const emptyIcon = document.createElement('span');
1170
+ emptyIcon.className = 'empty-icon';
1171
+ emptyIcon.textContent = '\uD83D\uDCCA';
1172
+
1173
+ const emptyText = document.createElement('span');
1174
+ emptyText.className = 'empty-text';
1175
+ emptyText.textContent = 'No resources found';
1176
+
1177
+ emptyState.appendChild(emptyIcon);
1178
+ emptyState.appendChild(emptyText);
1179
+ emptyCell.appendChild(emptyState);
1180
+ emptyRow.appendChild(emptyCell);
1181
+ tbody.appendChild(emptyRow);
1182
+ return;
1183
+ }
1184
+
1185
+ resources.forEach((resource) => {
1186
+ tbody.appendChild(createResourceRow(resource));
1187
+ });
1188
+
1189
+ updateMobileCards(resources);
1190
+ }
1191
+
1192
+ function createResourceRow(resource: UnifiedResource): HTMLElement {
1193
+ const row = document.createElement('tr');
1194
+ row.className = 'resource-row';
1195
+ row.dataset.resourceId = resource.id;
1196
+ row.dataset.resourceType = resource.type;
1197
+ row.dataset.expandable = 'true';
1198
+ row.tabIndex = 0;
1199
+ row.setAttribute('role', 'row');
1200
+
1201
+ // Name cell
1202
+ const nameCell = document.createElement('td');
1203
+ nameCell.className = 'cell-name';
1204
+ nameCell.dataset.label = 'Name';
1205
+ const nameContent = document.createElement('div');
1206
+ nameContent.className = 'name-content';
1207
+ const nameSpan = document.createElement('span');
1208
+ nameSpan.className = 'resource-name';
1209
+ nameSpan.textContent = resource.name;
1210
+ nameContent.appendChild(nameSpan);
1211
+ nameCell.appendChild(nameContent);
1212
+
1213
+ // Type cell
1214
+ const typeCell = document.createElement('td');
1215
+ typeCell.className = 'cell-type';
1216
+ typeCell.dataset.label = 'Type';
1217
+ const typeBadge = document.createElement('span');
1218
+ typeBadge.className = 'type-badge';
1219
+ typeBadge.dataset.type = resource.type;
1220
+ const typeIcon = document.createElement('span');
1221
+ typeIcon.className = 'type-icon';
1222
+ typeIcon.textContent = TYPE_ICONS[resource.type] || '\u2753';
1223
+ const typeLabel = document.createElement('span');
1224
+ typeLabel.className = 'type-label';
1225
+ typeLabel.textContent = ' ' + (TYPE_LABELS[resource.type] || resource.type);
1226
+ typeBadge.appendChild(typeIcon);
1227
+ typeBadge.appendChild(typeLabel);
1228
+ typeCell.appendChild(typeBadge);
1229
+
1230
+ // Project cell
1231
+ const projectCell = document.createElement('td');
1232
+ projectCell.className = 'cell-project';
1233
+ projectCell.dataset.label = 'Project';
1234
+ const projectName = document.createElement('span');
1235
+ projectName.className = 'project-name';
1236
+ projectName.textContent = resource.project || 'Unknown';
1237
+ projectCell.appendChild(projectName);
1238
+
1239
+ // Usage cell
1240
+ const usageCell = document.createElement('td');
1241
+ usageCell.className = 'cell-usage';
1242
+ usageCell.style.textAlign = 'right';
1243
+ usageCell.dataset.label = 'Usage';
1244
+ usageCell.dataset.value = String(resource.usage.value);
1245
+ const usageContent = document.createElement('div');
1246
+ usageContent.className = 'usage-content';
1247
+ const usageValue = document.createElement('span');
1248
+ usageValue.className = 'usage-value';
1249
+ usageValue.textContent = resource.usage.formatted;
1250
+ const usageUnit = document.createElement('span');
1251
+ usageUnit.className = 'usage-unit';
1252
+ usageUnit.textContent = ' ' + resource.usage.unit;
1253
+ usageContent.appendChild(usageValue);
1254
+ usageContent.appendChild(usageUnit);
1255
+ usageCell.appendChild(usageContent);
1256
+
1257
+ // Cost cell
1258
+ const costCell = document.createElement('td');
1259
+ costCell.className = 'cell-cost';
1260
+ costCell.style.textAlign = 'right';
1261
+ costCell.dataset.label = 'Cost';
1262
+ costCell.dataset.value = String(resource.costCurrent);
1263
+ const costValue = document.createElement('span');
1264
+ costValue.className = 'cost-value';
1265
+ costValue.textContent = formatCurrency(resource.costCurrent);
1266
+ costCell.appendChild(costValue);
1267
+
1268
+ // Change cell
1269
+ const deltaCell = document.createElement('td');
1270
+ deltaCell.className = 'cell-delta';
1271
+ deltaCell.style.textAlign = 'right';
1272
+ deltaCell.dataset.label = 'Change';
1273
+ deltaCell.dataset.value = String(resource.costDeltaPct);
1274
+ const deltaValue = document.createElement('span');
1275
+ deltaValue.className = 'delta-value ' + getDeltaClass(resource.costDeltaPct);
1276
+ deltaValue.textContent = formatDeltaPct(resource.costDeltaPct);
1277
+ deltaCell.appendChild(deltaValue);
1278
+
1279
+ // Status cell
1280
+ const statusCell = document.createElement('td');
1281
+ statusCell.className = 'cell-status';
1282
+ statusCell.dataset.label = 'Status';
1283
+ const statusIndicator = document.createElement('span');
1284
+ statusIndicator.className = 'status-indicator';
1285
+ statusIndicator.dataset.status = resource.status;
1286
+ statusIndicator.style.backgroundColor = STATUS_COLOURS[resource.status] || '#888';
1287
+ statusIndicator.title = `Status: ${resource.status}`;
1288
+ statusCell.appendChild(statusIndicator);
1289
+
1290
+ row.appendChild(nameCell);
1291
+ row.appendChild(typeCell);
1292
+ row.appendChild(projectCell);
1293
+ row.appendChild(usageCell);
1294
+ row.appendChild(costCell);
1295
+ row.appendChild(deltaCell);
1296
+ row.appendChild(statusCell);
1297
+
1298
+ return row;
1299
+ }
1300
+
1301
+ function updateMobileCards(resources: UnifiedResource[]): void {
1302
+ const cardsContainer = document.getElementById('unified-resource-table-cards');
1303
+ if (!cardsContainer) return;
1304
+
1305
+ cardsContainer.textContent = '';
1306
+
1307
+ resources.forEach((resource) => {
1308
+ const card = document.createElement('div');
1309
+ card.className = 'resource-card';
1310
+ card.dataset.resourceId = resource.id;
1311
+
1312
+ const cardHeader = document.createElement('div');
1313
+ cardHeader.className = 'card-header';
1314
+
1315
+ const cardName = document.createElement('span');
1316
+ cardName.className = 'card-name';
1317
+ cardName.textContent = resource.name;
1318
+
1319
+ const cardType = document.createElement('span');
1320
+ cardType.className = 'card-type';
1321
+ cardType.textContent = TYPE_LABELS[resource.type] || resource.type;
1322
+
1323
+ cardHeader.appendChild(cardName);
1324
+ cardHeader.appendChild(cardType);
1325
+ card.appendChild(cardHeader);
1326
+
1327
+ const cardBody = document.createElement('div');
1328
+ cardBody.className = 'card-body';
1329
+
1330
+ const rows = [
1331
+ { label: 'Project', value: resource.project || 'Unknown' },
1332
+ { label: 'Usage', value: resource.usage.formatted + ' ' + resource.usage.unit },
1333
+ { label: 'Cost', value: formatCurrency(resource.costCurrent) },
1334
+ ];
1335
+
1336
+ rows.forEach((row) => {
1337
+ const rowDiv = document.createElement('div');
1338
+ rowDiv.className = 'card-row';
1339
+
1340
+ const rowLabel = document.createElement('span');
1341
+ rowLabel.className = 'row-label';
1342
+ rowLabel.textContent = row.label;
1343
+
1344
+ const rowValue = document.createElement('span');
1345
+ rowValue.className = 'row-value';
1346
+ rowValue.textContent = row.value;
1347
+
1348
+ rowDiv.appendChild(rowLabel);
1349
+ rowDiv.appendChild(rowValue);
1350
+ cardBody.appendChild(rowDiv);
1351
+ });
1352
+
1353
+ card.appendChild(cardBody);
1354
+ cardsContainer.appendChild(card);
1355
+ });
1356
+ }
1357
+
1358
+ // ========== Table Sorting ==========
1359
+
1360
+ export function initSortableHeaders(): void {
1361
+ document.querySelectorAll('.sortable-table th.sortable').forEach((th) => {
1362
+ th.addEventListener('click', () => {
1363
+ const table = th.closest('table');
1364
+ if (!table) return;
1365
+
1366
+ const column = th.getAttribute('data-sort');
1367
+ const dataType = th.getAttribute('data-type') || 'string';
1368
+ if (!column) return;
1369
+
1370
+ sortTable(table, column, dataType);
1371
+ });
1372
+ });
1373
+ }
1374
+
1375
+ function sortTable(table: HTMLTableElement, column: string, dataType: string): void {
1376
+ const tbody = table.querySelector('tbody');
1377
+ if (!tbody) return;
1378
+
1379
+ const rows = Array.from(tbody.querySelectorAll('tr'));
1380
+ const th = table.querySelector(`th[data-sort="${column}"]`);
1381
+ const currentOrder = th?.getAttribute('data-order') || 'none';
1382
+ const newOrder = currentOrder === 'asc' ? 'desc' : 'asc';
1383
+
1384
+ // Reset all headers
1385
+ table.querySelectorAll('th').forEach((h) => h.setAttribute('data-order', 'none'));
1386
+ th?.setAttribute('data-order', newOrder);
1387
+
1388
+ rows.sort((a, b) => {
1389
+ const aCell =
1390
+ a.querySelector(`td[data-label="${column}"]`) || a.cells[getColumnIndex(table, column)];
1391
+ const bCell =
1392
+ b.querySelector(`td[data-label="${column}"]`) || b.cells[getColumnIndex(table, column)];
1393
+
1394
+ const aVal = aCell?.getAttribute('data-value') || aCell?.textContent?.trim() || '';
1395
+ const bVal = bCell?.getAttribute('data-value') || bCell?.textContent?.trim() || '';
1396
+
1397
+ if (dataType === 'number') {
1398
+ const aNum = parseFloat(aVal) || 0;
1399
+ const bNum = parseFloat(bVal) || 0;
1400
+ return newOrder === 'asc' ? aNum - bNum : bNum - aNum;
1401
+ }
1402
+
1403
+ return newOrder === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
1404
+ });
1405
+
1406
+ rows.forEach((row) => tbody.appendChild(row));
1407
+ }
1408
+
1409
+ function getColumnIndex(table: HTMLTableElement, column: string): number {
1410
+ const headers = Array.from(table.querySelectorAll('th'));
1411
+ return headers.findIndex((h) => h.getAttribute('data-sort') === column);
1412
+ }
1413
+
1414
+ // ========== Responsive Handling ==========
1415
+
1416
+ export function handleResponsiveSwitch(): void {
1417
+ const tableView = document.getElementById('unified-resource-table');
1418
+ const cardsView = document.getElementById('unified-resource-table-cards');
1419
+
1420
+ if (!tableView || !cardsView) return;
1421
+
1422
+ if (window.innerWidth < 768) {
1423
+ tableView.style.display = 'none';
1424
+ cardsView.style.display = 'block';
1425
+ } else {
1426
+ tableView.style.display = 'block';
1427
+ cardsView.style.display = 'none';
1428
+ }
1429
+ }
1430
+
1431
+ // ========== Data Loading ==========
1432
+
1433
+ export async function loadUsageData(nocache = false): Promise<void> {
1434
+ const { period, project, compare } = getSSRConfig();
1435
+
1436
+ // Show loading state - handle both overview section and main page containers
1437
+ const loadingContainer = document.getElementById('overview-loading');
1438
+ const contentContainer = document.getElementById('overview-content');
1439
+ const mainLoadingState = document.getElementById('loading-state');
1440
+ const mainDataContainer = document.getElementById('data-container');
1441
+ const mainErrorBanner = document.getElementById('error-banner');
1442
+
1443
+ if (loadingContainer) loadingContainer.style.display = 'block';
1444
+ if (contentContainer) contentContainer.style.display = 'none';
1445
+
1446
+ try {
1447
+ const params = new URLSearchParams({ period, project });
1448
+
1449
+ // Add nocache parameter if requested (bypasses KV cache)
1450
+ if (nocache) {
1451
+ params.set('nocache', 'true');
1452
+ }
1453
+
1454
+ // If comparison mode is active, fetch from /api/usage/compare instead
1455
+ if (compare !== 'none') {
1456
+ params.set('compare', compare);
1457
+ // Include credentials to pass Cloudflare Access JWT cookie
1458
+ const response = await fetch(`/api/usage/compare?${params}`, {
1459
+ credentials: 'include',
1460
+ });
1461
+
1462
+ if (!response.ok) {
1463
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1464
+ }
1465
+
1466
+ const json = await response.json();
1467
+
1468
+ if (!json.success) {
1469
+ throw new Error(json.error || 'Invalid response');
1470
+ }
1471
+
1472
+ // Compare API returns { current: { data, costs, ... }, prior: { data, costs, ... } }
1473
+ renderOverviewData({
1474
+ usage: json.current.data,
1475
+ costs: json.current.costs,
1476
+ thresholds: json.thresholds || { alerts: [], counts: { low: 0, medium: 0, high: 0 } },
1477
+ priorUsage: json.prior.data,
1478
+ priorCosts: json.prior.costs,
1479
+ });
1480
+ } else {
1481
+ // Standard usage fetch (no comparison)
1482
+ // Include credentials to pass Cloudflare Access JWT cookie
1483
+ const response = await fetch(`/api/usage?${params}`, {
1484
+ credentials: 'include',
1485
+ });
1486
+
1487
+ if (!response.ok) {
1488
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1489
+ }
1490
+
1491
+ const json = await response.json();
1492
+
1493
+ if (!json.success) {
1494
+ throw new Error(json.error || 'Invalid response');
1495
+ }
1496
+
1497
+ // API returns { data: UsageData, costs: CostBreakdown, thresholds: ThresholdData }
1498
+ renderOverviewData({
1499
+ usage: json.data,
1500
+ costs: json.costs,
1501
+ thresholds: json.thresholds,
1502
+ });
1503
+ }
1504
+
1505
+ // Hide main page loading state, show data container
1506
+ if (mainLoadingState) mainLoadingState.style.display = 'none';
1507
+ if (mainDataContainer) mainDataContainer.style.display = 'block';
1508
+ if (mainErrorBanner) mainErrorBanner.style.display = 'none';
1509
+ } catch (error) {
1510
+ console.error('Failed to load usage data:', error);
1511
+ const errorContainer = document.getElementById('overview-error');
1512
+ if (errorContainer) {
1513
+ errorContainer.style.display = 'block';
1514
+ errorContainer.textContent =
1515
+ 'Failed to load usage data: ' + (error instanceof Error ? error.message : 'Unknown error');
1516
+ }
1517
+ // Show main page error banner
1518
+ if (mainErrorBanner) {
1519
+ mainErrorBanner.style.display = 'block';
1520
+ const errorMsg = document.getElementById('error-message');
1521
+ if (errorMsg) {
1522
+ errorMsg.textContent = error instanceof Error ? error.message : 'Unknown error';
1523
+ }
1524
+ }
1525
+ if (mainLoadingState) mainLoadingState.style.display = 'none';
1526
+ } finally {
1527
+ if (loadingContainer) loadingContainer.style.display = 'none';
1528
+ if (contentContainer) contentContainer.style.display = 'block';
1529
+ }
1530
+ }
1531
+
1532
+ function renderOverviewData(data: {
1533
+ usage: UsageData;
1534
+ costs: CostBreakdown;
1535
+ thresholds: ThresholdData;
1536
+ priorUsage?: UsageData;
1537
+ priorCosts?: CostBreakdown;
1538
+ }): void {
1539
+ const { usage, costs, thresholds, priorUsage, priorCosts } = data;
1540
+
1541
+ // Render threshold banner
1542
+ const bannerContainer = document.getElementById('threshold-banner-container');
1543
+ if (bannerContainer) {
1544
+ bannerContainer.textContent = '';
1545
+ const banner = buildCompactThresholdBanner(thresholds);
1546
+ if (banner) {
1547
+ bannerContainer.appendChild(banner);
1548
+ }
1549
+ }
1550
+
1551
+ // Render cost breakdown chart (Bug fix: ID was 'cost-breakdown-container', corrected to 'cost-donut-container')
1552
+ const costContainer = document.getElementById('cost-donut-container');
1553
+ if (costContainer) {
1554
+ const items = [
1555
+ { label: 'Workers', amount: costs.workers || 0, colour: '#3B82F6' },
1556
+ { label: 'D1', amount: costs.d1 || 0, colour: '#10B981' },
1557
+ { label: 'KV', amount: costs.kv || 0, colour: '#F59E0B' },
1558
+ { label: 'R2', amount: costs.r2 || 0, colour: '#EF4444' },
1559
+ { label: 'Vectorize', amount: costs.vectorize || 0, colour: '#8B5CF6' },
1560
+ { label: 'AI Gateway', amount: costs.aiGateway || 0, colour: '#EC4899' },
1561
+ { label: 'Pages', amount: costs.pages || 0, colour: '#6366F1' },
1562
+ { label: 'Durable Objects', amount: costs.durableObjects || 0, colour: '#14B8A6' },
1563
+ ].filter((i) => i.amount > 0);
1564
+
1565
+ buildCostBreakdownCard(costContainer, items);
1566
+ }
1567
+
1568
+ // Update total cost display (Bug fix: ID was 'total-cost-value', corrected to 'cost-value')
1569
+ const totalCostEl = document.getElementById('cost-value');
1570
+ if (totalCostEl) {
1571
+ totalCostEl.textContent = formatCurrency(costs.total || 0);
1572
+ }
1573
+
1574
+ // Transform current resources
1575
+ let resources = transformToUnifiedResources(usage, costs, identifyProject);
1576
+
1577
+ // If comparison data is available, apply it to calculate deltas
1578
+ if (priorUsage && priorCosts) {
1579
+ const priorResources = transformToUnifiedResources(priorUsage, priorCosts, identifyProject);
1580
+ resources = applyComparisonData(resources, priorResources);
1581
+ }
1582
+
1583
+ // Render resources table
1584
+ renderUnifiedResourceTable(resources);
1585
+
1586
+ // Update workers breakdown table (Enhancement #1: per-project Workers breakdown)
1587
+ if (usage.workers && usage.workers.length > 0) {
1588
+ updateWorkersBreakdownTable(usage.workers);
1589
+ }
1590
+
1591
+ // Update resource count
1592
+ const countEl = document.getElementById('resource-count-value');
1593
+ if (countEl) {
1594
+ countEl.textContent = String(resources.length);
1595
+ }
1596
+ }
1597
+
1598
+ // ========== Initialization ==========
1599
+
1600
+ export function initOverviewTab(): void {
1601
+ loadUsageData();
1602
+ initSortableHeaders();
1603
+ handleResponsiveSwitch();
1604
+ window.addEventListener('resize', handleResponsiveSwitch);
1605
+
1606
+ // Initialize daily chart data (nanostores)
1607
+ syncFromURL(); // Read initial state from URL params
1608
+ initSubscriptions(); // Set up auto-fetching when filters change
1609
+ fetchDailyData(); // Fetch initial daily data for chart
1610
+
1611
+ // Refresh button handler (bypasses cache)
1612
+ const refreshBtn = document.getElementById('refresh-data');
1613
+ if (refreshBtn) {
1614
+ refreshBtn.addEventListener('click', async () => {
1615
+ const icon = refreshBtn.querySelector('.refresh-icon');
1616
+ if (icon) {
1617
+ icon.classList.add('spinning');
1618
+ }
1619
+ refreshBtn.setAttribute('disabled', 'true');
1620
+
1621
+ try {
1622
+ await fetchDailyData({ nocache: true });
1623
+ // Also reload resource table data
1624
+ await loadUsageData(true);
1625
+ } finally {
1626
+ if (icon) {
1627
+ icon.classList.remove('spinning');
1628
+ }
1629
+ refreshBtn.removeAttribute('disabled');
1630
+ }
1631
+ });
1632
+ }
1633
+ }