@littlebearapps/platform-admin-sdk 2.1.0 → 2.3.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 (122) hide show
  1. package/README.md +2 -5
  2. package/dist/check-upgrade.d.ts +29 -0
  3. package/dist/check-upgrade.js +97 -0
  4. package/dist/index.js +59 -4
  5. package/dist/manifest.d.ts +2 -0
  6. package/dist/scaffold.js +5 -1
  7. package/dist/templates.d.ts +6 -1
  8. package/dist/templates.js +141 -3
  9. package/dist/upgrade.d.ts +1 -0
  10. package/dist/upgrade.js +21 -2
  11. package/package.json +1 -1
  12. package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
  13. package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
  14. package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
  15. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  16. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  17. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  18. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  19. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  20. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  21. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  22. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  23. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  24. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  25. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  26. package/templates/full/dashboard/src/pages/map.astro +561 -0
  27. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  28. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  29. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  30. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  31. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  32. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  33. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  34. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  35. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  36. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  37. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  38. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  39. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  40. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  41. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  42. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  43. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  44. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  45. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  46. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  47. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  48. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  49. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  50. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  51. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  52. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  53. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  54. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  55. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  56. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  57. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  58. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  59. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  60. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  61. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  62. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  63. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  64. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  65. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  66. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  67. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  68. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  69. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  70. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  71. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  72. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  73. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  74. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  75. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  76. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  77. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  78. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  79. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  80. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  81. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  82. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  83. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  84. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  85. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  86. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  87. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  88. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  89. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  90. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  91. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  92. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  93. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  94. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  95. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  96. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  97. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  98. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  99. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  100. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  101. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  102. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  103. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  104. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  105. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  106. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  107. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  108. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  109. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  110. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  111. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  112. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  113. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  114. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  115. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  116. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  117. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  118. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  119. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  120. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  121. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  122. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
@@ -0,0 +1,1737 @@
1
+ ---
2
+ /**
3
+ * UnifiedResourceTable Component (task-17.8)
4
+ *
5
+ * Main table component for unified resource display with multi-column sorting.
6
+ * Primary view in the Usage Dashboard showing all Cloudflare resources.
7
+ */
8
+
9
+ import {
10
+ type ResourceType,
11
+ type ResourceStatus,
12
+ type UnifiedResource,
13
+ RESOURCE_TYPE_ICONS,
14
+ RESOURCE_TYPE_LABELS,
15
+ STATUS_COLOURS,
16
+ formatCurrency,
17
+ formatDeltaPct,
18
+ getDeltaClass,
19
+ formatLimitPct,
20
+ getLimitPctClass,
21
+ } from './types';
22
+
23
+ export interface Props {
24
+ resources: UnifiedResource[];
25
+ sortColumn: string;
26
+ sortDirection: 'asc' | 'desc';
27
+ tableId?: string;
28
+ }
29
+
30
+ const {
31
+ resources = [],
32
+ sortColumn = 'costCurrent',
33
+ sortDirection = 'desc',
34
+ tableId = 'unified-resource-table',
35
+ } = Astro.props;
36
+
37
+ // Use imported constants
38
+ const typeIcons = RESOURCE_TYPE_ICONS;
39
+ const typeLabels = RESOURCE_TYPE_LABELS;
40
+ const statusColours = STATUS_COLOURS;
41
+
42
+ // Column definitions
43
+ const columns = [
44
+ { key: 'name', label: 'Resource Name', type: 'string', align: 'left' },
45
+ { key: 'type', label: 'Type', type: 'string', align: 'left' },
46
+ { key: 'project', label: 'Project', type: 'string', align: 'left' },
47
+ { key: 'usage', label: 'Usage', type: 'number', align: 'right' },
48
+ { key: 'limitPct', label: '% of Limit', type: 'number', align: 'right' },
49
+ { key: 'costCurrent', label: 'Cost', type: 'number', align: 'right' },
50
+ { key: 'costDeltaPct', label: 'Change', type: 'number', align: 'right' },
51
+ { key: 'status', label: 'Status', type: 'string', align: 'center' },
52
+ ];
53
+ ---
54
+
55
+ <div class="unified-table-container" data-table-id={tableId}>
56
+ <div class="table-wrapper">
57
+ <table class="unified-resource-table" role="grid" aria-label="Resource usage table">
58
+ <thead>
59
+ <tr>
60
+ {
61
+ columns.map((col) => (
62
+ <th
63
+ data-sort={col.key}
64
+ data-type={col.type}
65
+ data-align={col.align}
66
+ class:list={[
67
+ 'sortable',
68
+ col.key === sortColumn && 'sorted',
69
+ col.key === sortColumn && sortDirection,
70
+ ]}
71
+ scope="col"
72
+ role="columnheader"
73
+ aria-sort={
74
+ col.key === sortColumn
75
+ ? sortDirection === 'asc'
76
+ ? 'ascending'
77
+ : 'descending'
78
+ : 'none'
79
+ }
80
+ tabindex="0"
81
+ >
82
+ <span class="header-content">
83
+ {col.label}
84
+ <span class="sort-icon" aria-hidden="true" />
85
+ </span>
86
+ </th>
87
+ ))
88
+ }
89
+ </tr>
90
+ </thead>
91
+ <tbody id={`${tableId}-tbody`}>
92
+ {
93
+ resources.length === 0 && (
94
+ <tr class="empty-row">
95
+ <td colspan={columns.length} class="empty-cell">
96
+ <div class="empty-state">
97
+ <span class="empty-icon">📊</span>
98
+ <span class="empty-text">No resources found</span>
99
+ </div>
100
+ </td>
101
+ </tr>
102
+ )
103
+ }
104
+ {
105
+ resources.map((resource) => (
106
+ <tr
107
+ class="resource-row"
108
+ data-resource-id={resource.id}
109
+ data-resource-type={resource.type}
110
+ data-expandable="true"
111
+ tabindex="0"
112
+ role="row"
113
+ >
114
+ <td class="cell-name" data-label="Name">
115
+ <div class="name-content">
116
+ <span class="resource-name">{resource.name}</span>
117
+ </div>
118
+ </td>
119
+ <td class="cell-type" data-label="Type">
120
+ <span class="type-badge" data-type={resource.type}>
121
+ <span class="type-icon">{typeIcons[resource.type]}</span>
122
+ <span class="type-label">{typeLabels[resource.type]}</span>
123
+ </span>
124
+ </td>
125
+ <td class="cell-project" data-label="Project">
126
+ {resource.repoUrl ? (
127
+ <a
128
+ href={resource.repoUrl}
129
+ target="_blank"
130
+ rel="noopener noreferrer"
131
+ class="project-link"
132
+ >
133
+ <span class="project-name">{resource.project || 'Unknown'}</span>
134
+ <svg
135
+ class="external-link-icon"
136
+ width="12"
137
+ height="12"
138
+ viewBox="0 0 24 24"
139
+ fill="none"
140
+ stroke="currentColor"
141
+ stroke-width="2"
142
+ >
143
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />
144
+ </svg>
145
+ </a>
146
+ ) : (
147
+ <span class="project-name">{resource.project || 'Unknown'}</span>
148
+ )}
149
+ </td>
150
+ <td class="cell-usage" data-label="Usage" data-value={resource.usage.value}>
151
+ <div class="usage-content">
152
+ <span class="usage-value">{resource.usage.formatted}</span>
153
+ <span class="usage-unit">{resource.usage.unit}</span>
154
+ </div>
155
+ </td>
156
+ <td
157
+ class="cell-limit-pct"
158
+ data-label="% of Limit"
159
+ data-value={resource.limitPct ?? 0}
160
+ >
161
+ <span class:list={['limit-value', getLimitPctClass(resource.limitPct)]}>
162
+ {formatLimitPct(resource.limitPct)}
163
+ </span>
164
+ </td>
165
+ <td class="cell-cost" data-label="Cost" data-value={resource.costCurrent}>
166
+ <span class="cost-value">{formatCurrency(resource.costCurrent)}</span>
167
+ </td>
168
+ <td class="cell-delta" data-label="Change" data-value={resource.costDeltaPct}>
169
+ <span class:list={['delta-value', getDeltaClass(resource.costDeltaPct)]}>
170
+ {formatDeltaPct(resource.costDeltaPct)}
171
+ </span>
172
+ </td>
173
+ <td class="cell-status" data-label="Status">
174
+ <span
175
+ class="status-indicator"
176
+ data-status={resource.status}
177
+ style={`display: inline-block; width: 10px; height: 10px; border-radius: 50%; background-color: ${statusColours[resource.status]};`}
178
+ title={`Status: ${resource.status}`}
179
+ />
180
+ </td>
181
+ </tr>
182
+ ))
183
+ }
184
+ </tbody>
185
+ </table>
186
+ </div>
187
+
188
+ <!-- Mobile card view -->
189
+ <div id={`${tableId}-cards`} class="mobile-cards">
190
+ {
191
+ resources.map((resource) => (
192
+ <div class="mobile-card" data-resource-id={resource.id}>
193
+ <div class="card-header-row">
194
+ <div class="card-title-area">
195
+ <span
196
+ class="status-dot"
197
+ style={`display: inline-block; width: 8px; height: 8px; border-radius: 50%; background-color: ${statusColours[resource.status]};`}
198
+ />
199
+ <span class="card-name">{resource.name}</span>
200
+ </div>
201
+ <span class="type-badge-mini" data-type={resource.type}>
202
+ {typeIcons[resource.type]} {typeLabels[resource.type]}
203
+ </span>
204
+ </div>
205
+ <div class="card-body">
206
+ <div class="card-metric">
207
+ <span class="metric-label">Project</span>
208
+ {resource.repoUrl ? (
209
+ <a
210
+ href={resource.repoUrl}
211
+ target="_blank"
212
+ rel="noopener noreferrer"
213
+ class="metric-value project-link-mobile"
214
+ >
215
+ {resource.project || 'Unknown'}
216
+ <svg
217
+ class="external-link-icon"
218
+ width="10"
219
+ height="10"
220
+ viewBox="0 0 24 24"
221
+ fill="none"
222
+ stroke="currentColor"
223
+ stroke-width="2"
224
+ >
225
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />
226
+ </svg>
227
+ </a>
228
+ ) : (
229
+ <span class="metric-value">{resource.project || 'Unknown'}</span>
230
+ )}
231
+ </div>
232
+ <div class="card-metric">
233
+ <span class="metric-label">Usage</span>
234
+ <span class="metric-value">
235
+ {resource.usage.formatted} {resource.usage.unit}
236
+ </span>
237
+ </div>
238
+ <div class="card-metric">
239
+ <span class="metric-label">% of Limit</span>
240
+ <span class:list={['metric-value', getLimitPctClass(resource.limitPct)]}>
241
+ {formatLimitPct(resource.limitPct)}
242
+ </span>
243
+ </div>
244
+ <div class="card-metric highlight">
245
+ <span class="metric-label">Cost</span>
246
+ <span class="metric-value">{formatCurrency(resource.costCurrent)}</span>
247
+ </div>
248
+ <div class="card-metric">
249
+ <span class="metric-label">Change</span>
250
+ <span class:list={['metric-value', getDeltaClass(resource.costDeltaPct)]}>
251
+ {formatDeltaPct(resource.costDeltaPct)}
252
+ </span>
253
+ </div>
254
+ </div>
255
+ </div>
256
+ ))
257
+ }
258
+ </div>
259
+ </div>
260
+
261
+ <style>
262
+ /* ================================================
263
+ * UnifiedResourceTable - Theme-Aware Styling
264
+ * Uses CSS custom properties from design-tokens.ts
265
+ * ================================================ */
266
+
267
+ .unified-table-container {
268
+ width: 100%;
269
+ overflow-x: hidden;
270
+ }
271
+
272
+ .table-wrapper {
273
+ overflow-x: auto;
274
+ border: 1px solid var(--usage-border-default, #e5e7eb);
275
+ border-radius: var(--usage-radius-lg, 8px);
276
+ background-color: var(--usage-bg-primary, white);
277
+ box-shadow: var(--usage-shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
278
+ }
279
+
280
+ .unified-resource-table {
281
+ width: 100%;
282
+ border-collapse: collapse;
283
+ font-family: var(--usage-font-sans, -apple-system, BlinkMacSystemFont, sans-serif);
284
+ font-size: 0.875rem;
285
+ }
286
+
287
+ /* Header styles */
288
+ thead {
289
+ position: sticky;
290
+ top: 0;
291
+ z-index: 10;
292
+ background-color: var(--usage-bg-secondary, #f9fafb);
293
+ }
294
+
295
+ th {
296
+ padding: 0.75rem 1rem;
297
+ text-align: left;
298
+ font-weight: 600;
299
+ font-size: 0.75rem;
300
+ color: var(--usage-text-secondary, #656d76);
301
+ text-transform: uppercase;
302
+ letter-spacing: 0.05em;
303
+ border-bottom: 1px solid var(--usage-border-default, #e5e7eb);
304
+ white-space: nowrap;
305
+ cursor: pointer;
306
+ user-select: none;
307
+ transition:
308
+ background-color var(--usage-transition-fast, 150ms ease),
309
+ color var(--usage-transition-fast, 150ms ease);
310
+ }
311
+
312
+ th:hover {
313
+ background-color: var(--usage-bg-hover, #eaeef2);
314
+ color: var(--usage-text-primary, #1f2328);
315
+ }
316
+
317
+ th[data-align='right'] {
318
+ text-align: right;
319
+ }
320
+
321
+ th[data-align='center'] {
322
+ text-align: center;
323
+ }
324
+
325
+ .header-content {
326
+ display: inline-flex;
327
+ align-items: center;
328
+ gap: 0.375rem;
329
+ }
330
+
331
+ .sort-icon {
332
+ opacity: 0.3;
333
+ font-size: 0.625rem;
334
+ transition: opacity var(--usage-transition-fast, 150ms ease);
335
+ }
336
+
337
+ .sort-icon::after {
338
+ content: '↕';
339
+ }
340
+
341
+ th.sorted .sort-icon {
342
+ opacity: 1;
343
+ color: var(--usage-accent-blue, #0969da);
344
+ }
345
+
346
+ th.sorted.asc .sort-icon::after {
347
+ content: '↑';
348
+ }
349
+
350
+ th.sorted.desc .sort-icon::after {
351
+ content: '↓';
352
+ }
353
+
354
+ th.sorted {
355
+ color: var(--usage-accent-blue, #0969da);
356
+ }
357
+
358
+ /* Body styles */
359
+ tbody tr {
360
+ border-bottom: 1px solid var(--usage-border-subtle, #e8ecef);
361
+ transition: background-color var(--usage-transition-fast, 150ms ease);
362
+ }
363
+
364
+ tbody tr:last-child {
365
+ border-bottom: none;
366
+ }
367
+
368
+ tbody tr:hover {
369
+ background-color: var(--usage-bg-hover, #eaeef2);
370
+ }
371
+
372
+ tbody tr:focus {
373
+ outline: 2px solid var(--usage-accent-blue, #0969da);
374
+ outline-offset: -2px;
375
+ }
376
+
377
+ td {
378
+ padding: 0.75rem 1rem;
379
+ vertical-align: middle;
380
+ color: var(--usage-text-primary, #1f2328);
381
+ }
382
+
383
+ /* Cell-specific styles */
384
+ .cell-name {
385
+ font-weight: 500;
386
+ max-width: 250px;
387
+ }
388
+
389
+ .resource-name {
390
+ display: block;
391
+ overflow: hidden;
392
+ text-overflow: ellipsis;
393
+ white-space: nowrap;
394
+ font-family: var(--usage-font-mono, 'JetBrains Mono', monospace);
395
+ font-size: 0.8125rem;
396
+ }
397
+
398
+ .cell-type {
399
+ padding-right: 0.5rem;
400
+ }
401
+
402
+ .type-badge {
403
+ display: inline-flex;
404
+ align-items: center;
405
+ gap: 0.25rem;
406
+ padding: 0.25rem 0.625rem;
407
+ border-radius: 9999px;
408
+ background-color: var(--usage-bg-tertiary, #f0f2f4);
409
+ font-size: 0.75rem;
410
+ border: 1px solid var(--usage-border-subtle, #e8ecef);
411
+ transition: all var(--usage-transition-fast, 150ms ease);
412
+ }
413
+
414
+ .type-badge:hover {
415
+ background-color: var(--usage-bg-hover, #eaeef2);
416
+ border-color: var(--usage-border-default, #d1d9e0);
417
+ }
418
+
419
+ /* Resource type colours */
420
+ .type-badge[data-type='worker'] {
421
+ --badge-accent: var(--usage-accent-blue, #0969da);
422
+ }
423
+ .type-badge[data-type='d1'] {
424
+ --badge-accent: var(--usage-accent-purple, #8250df);
425
+ }
426
+ .type-badge[data-type='kv'] {
427
+ --badge-accent: var(--usage-accent-green, #1a7f37);
428
+ }
429
+ .type-badge[data-type='r2'] {
430
+ --badge-accent: var(--usage-accent-yellow, #9a6700);
431
+ }
432
+ .type-badge[data-type='pages'] {
433
+ --badge-accent: var(--usage-accent-blue, #0969da);
434
+ }
435
+ .type-badge[data-type='vectorize'] {
436
+ --badge-accent: var(--usage-accent-purple, #8250df);
437
+ }
438
+ .type-badge[data-type='ai-gateway'] {
439
+ --badge-accent: var(--usage-accent-green, #1a7f37);
440
+ }
441
+ .type-badge[data-type='queues'] {
442
+ --badge-accent: var(--usage-accent-yellow, #9a6700);
443
+ }
444
+ .type-badge[data-type='workflows'] {
445
+ --badge-accent: var(--usage-accent-purple, #8250df);
446
+ }
447
+ .type-badge[data-type='do'] {
448
+ --badge-accent: var(--usage-accent-red, #cf222e);
449
+ }
450
+
451
+ .type-icon {
452
+ font-size: 0.75rem;
453
+ line-height: 1;
454
+ }
455
+
456
+ .type-label {
457
+ color: var(--badge-accent, var(--usage-text-secondary, #656d76));
458
+ font-weight: 600;
459
+ }
460
+
461
+ .cell-project {
462
+ color: var(--usage-text-secondary, #656d76);
463
+ font-size: 0.8125rem;
464
+ }
465
+
466
+ .cell-usage,
467
+ .cell-cost,
468
+ .cell-delta {
469
+ text-align: right;
470
+ white-space: nowrap;
471
+ }
472
+
473
+ .usage-content {
474
+ display: inline-flex;
475
+ flex-direction: column;
476
+ align-items: flex-end;
477
+ gap: 0.125rem;
478
+ }
479
+
480
+ .usage-value {
481
+ font-family: var(--usage-font-mono, 'JetBrains Mono', monospace);
482
+ font-size: 0.875rem;
483
+ font-weight: 600;
484
+ font-variant-numeric: tabular-nums;
485
+ color: var(--usage-text-primary, #1f2328);
486
+ }
487
+
488
+ .usage-unit {
489
+ font-size: 0.625rem;
490
+ color: var(--usage-text-muted, #8c959f);
491
+ text-transform: uppercase;
492
+ letter-spacing: 0.05em;
493
+ }
494
+
495
+ .cost-value {
496
+ font-family: var(--usage-font-mono, 'JetBrains Mono', monospace);
497
+ font-size: 0.875rem;
498
+ font-weight: 600;
499
+ font-variant-numeric: tabular-nums;
500
+ color: var(--usage-text-primary, #1f2328);
501
+ }
502
+
503
+ .delta-value {
504
+ font-family: var(--usage-font-mono, 'JetBrains Mono', monospace);
505
+ font-size: 0.75rem;
506
+ font-weight: 600;
507
+ padding: 0.125rem 0.5rem;
508
+ border-radius: var(--usage-radius-sm, 4px);
509
+ }
510
+
511
+ .delta-up {
512
+ color: var(--usage-status-critical, #cf222e);
513
+ background-color: var(--usage-status-critical-bg, #ffebe9);
514
+ }
515
+
516
+ .delta-down {
517
+ color: var(--usage-status-ok, #1a7f37);
518
+ background-color: var(--usage-status-ok-bg, #dafbe1);
519
+ }
520
+
521
+ /* Project link styles */
522
+ .project-link {
523
+ display: inline-flex;
524
+ align-items: center;
525
+ gap: 4px;
526
+ color: var(--usage-primary, #2563eb);
527
+ text-decoration: none;
528
+ transition: color 0.15s ease;
529
+ }
530
+
531
+ .project-link:hover {
532
+ color: var(--usage-primary-hover, #1d4ed8);
533
+ text-decoration: underline;
534
+ }
535
+
536
+ .external-link-icon {
537
+ opacity: 0.6;
538
+ flex-shrink: 0;
539
+ }
540
+
541
+ .project-link:hover .external-link-icon {
542
+ opacity: 1;
543
+ }
544
+
545
+ .project-link-mobile {
546
+ color: var(--usage-primary, #2563eb);
547
+ text-decoration: none;
548
+ }
549
+
550
+ /* Limit percentage styles */
551
+ .cell-limit-pct {
552
+ text-align: right;
553
+ }
554
+
555
+ .limit-value {
556
+ font-size: 0.8125rem;
557
+ font-weight: 500;
558
+ padding: 2px 8px;
559
+ border-radius: 4px;
560
+ }
561
+
562
+ .limit-ok {
563
+ color: var(--usage-status-ok, #1a7f37);
564
+ background-color: var(--usage-status-ok-bg, #dafbe1);
565
+ }
566
+
567
+ .limit-warning {
568
+ color: var(--usage-status-warning, #9a6700);
569
+ background-color: var(--usage-status-warning-bg, #fff8c5);
570
+ }
571
+
572
+ .limit-high {
573
+ color: var(--usage-status-high, #bc4c00);
574
+ background-color: var(--usage-status-high-bg, #fff1e5);
575
+ }
576
+
577
+ .limit-critical {
578
+ color: var(--usage-status-critical, #cf222e);
579
+ background-color: var(--usage-status-critical-bg, #ffebe9);
580
+ }
581
+
582
+ .limit-neutral {
583
+ color: var(--usage-text-muted, #656d76);
584
+ background-color: transparent;
585
+ }
586
+
587
+ .delta-neutral {
588
+ color: var(--usage-text-muted, #8c959f);
589
+ background-color: transparent;
590
+ }
591
+
592
+ .delta-new {
593
+ color: var(--usage-accent-blue, #0969da);
594
+ background-color: rgba(9, 105, 218, 0.1);
595
+ font-size: 0.625rem;
596
+ text-transform: uppercase;
597
+ letter-spacing: 0.05em;
598
+ }
599
+
600
+ .cell-status {
601
+ text-align: center;
602
+ }
603
+
604
+ /* Use :global() to avoid Astro scoping mismatch in dynamic loops */
605
+ :global(.status-indicator) {
606
+ display: inline-block;
607
+ width: 10px;
608
+ height: 10px;
609
+ border-radius: 50%;
610
+ box-shadow: 0 0 0 2px var(--usage-bg-primary, white);
611
+ }
612
+
613
+ /* Empty state */
614
+ .empty-row {
615
+ background-color: transparent !important;
616
+ }
617
+
618
+ .empty-cell {
619
+ padding: 3rem 1rem;
620
+ text-align: center;
621
+ }
622
+
623
+ .empty-state {
624
+ display: flex;
625
+ flex-direction: column;
626
+ align-items: center;
627
+ gap: 0.75rem;
628
+ color: var(--usage-text-muted, #8c959f);
629
+ }
630
+
631
+ .empty-icon {
632
+ font-size: 2.5rem;
633
+ opacity: 0.5;
634
+ }
635
+
636
+ .empty-text {
637
+ font-size: 0.875rem;
638
+ font-family: var(--usage-font-sans);
639
+ }
640
+
641
+ /* Expanded row styles */
642
+ .resource-row.expanded {
643
+ background-color: rgba(9, 105, 218, 0.05);
644
+ }
645
+
646
+ .resource-row[data-expandable='true'] {
647
+ cursor: pointer;
648
+ }
649
+
650
+ .resource-row[data-expandable='true']:hover .resource-name {
651
+ color: var(--usage-accent-blue, #0969da);
652
+ }
653
+
654
+ .expansion-row {
655
+ background-color: var(--usage-bg-secondary, #f6f8fa);
656
+ }
657
+
658
+ .expansion-cell {
659
+ padding: 1rem 1.5rem;
660
+ border-bottom: 1px solid var(--usage-border-default, #d1d9e0);
661
+ }
662
+
663
+ .expansion-content {
664
+ animation: slideDown 0.2s ease-out;
665
+ }
666
+
667
+ @keyframes slideDown {
668
+ from {
669
+ opacity: 0;
670
+ transform: translateY(-10px);
671
+ }
672
+ to {
673
+ opacity: 1;
674
+ transform: translateY(0);
675
+ }
676
+ }
677
+
678
+ .sparkline-loading {
679
+ text-align: center;
680
+ padding: 1rem;
681
+ color: var(--usage-text-muted, #8c959f);
682
+ font-size: 0.875rem;
683
+ font-family: var(--usage-font-sans);
684
+ }
685
+
686
+ .sparklines-container {
687
+ display: grid;
688
+ grid-template-columns: 1fr 1fr auto;
689
+ gap: 1.5rem;
690
+ align-items: start;
691
+ }
692
+
693
+ .sparkline-section {
694
+ display: flex;
695
+ flex-direction: column;
696
+ gap: 0.5rem;
697
+ }
698
+
699
+ .sparkline-header {
700
+ display: flex;
701
+ justify-content: space-between;
702
+ align-items: baseline;
703
+ gap: 0.5rem;
704
+ }
705
+
706
+ .sparkline-title {
707
+ font-family: var(--usage-font-sans);
708
+ font-size: 0.75rem;
709
+ font-weight: 600;
710
+ color: var(--usage-text-secondary, #656d76);
711
+ text-transform: uppercase;
712
+ letter-spacing: 0.05em;
713
+ }
714
+
715
+ .sparkline-current {
716
+ font-family: var(--usage-font-mono, 'JetBrains Mono', monospace);
717
+ font-size: 0.875rem;
718
+ font-weight: 700;
719
+ color: var(--usage-text-primary, #1f2328);
720
+ font-variant-numeric: tabular-nums;
721
+ }
722
+
723
+ .sparkline-svg {
724
+ width: 100%;
725
+ height: 40px;
726
+ }
727
+
728
+ .sparkline-stats {
729
+ display: flex;
730
+ flex-direction: column;
731
+ gap: 0.5rem;
732
+ padding: 0.625rem 0.875rem;
733
+ background-color: var(--usage-bg-primary, white);
734
+ border: 1px solid var(--usage-border-default, #d1d9e0);
735
+ border-radius: var(--usage-radius-md, 6px);
736
+ min-width: 140px;
737
+ }
738
+
739
+ .stat-item {
740
+ display: flex;
741
+ justify-content: space-between;
742
+ align-items: center;
743
+ gap: 0.75rem;
744
+ }
745
+
746
+ .stat-label {
747
+ font-family: var(--usage-font-sans);
748
+ font-size: 0.625rem;
749
+ color: var(--usage-text-muted, #8c959f);
750
+ text-transform: uppercase;
751
+ letter-spacing: 0.05em;
752
+ }
753
+
754
+ .stat-value {
755
+ font-family: var(--usage-font-mono, 'JetBrains Mono', monospace);
756
+ font-size: 0.75rem;
757
+ font-weight: 600;
758
+ color: var(--usage-text-primary, #1f2328);
759
+ font-variant-numeric: tabular-nums;
760
+ }
761
+
762
+ /* Cloudflare Deep Link */
763
+ .sparkline-right-section {
764
+ display: flex;
765
+ flex-direction: column;
766
+ gap: 0.75rem;
767
+ }
768
+
769
+ .cf-link-section {
770
+ display: flex;
771
+ justify-content: flex-end;
772
+ }
773
+
774
+ .cf-dashboard-link {
775
+ display: inline-flex;
776
+ align-items: center;
777
+ gap: 0.375rem;
778
+ padding: 0.375rem 0.75rem;
779
+ border: 1px solid var(--usage-accent-yellow, #9a6700);
780
+ border-radius: var(--usage-radius-md, 6px);
781
+ background-color: var(--usage-status-warning-bg, #fff8c5);
782
+ color: var(--usage-accent-yellow, #9a6700);
783
+ font-family: var(--usage-font-sans);
784
+ font-size: 0.75rem;
785
+ font-weight: 500;
786
+ text-decoration: none;
787
+ transition: all var(--usage-transition-fast, 150ms ease);
788
+ }
789
+
790
+ .cf-dashboard-link:hover {
791
+ background-color: rgba(154, 103, 0, 0.15);
792
+ transform: translateY(-1px);
793
+ }
794
+
795
+ .cf-dashboard-link svg {
796
+ flex-shrink: 0;
797
+ }
798
+
799
+ @media (max-width: 640px) {
800
+ .sparklines-container {
801
+ grid-template-columns: 1fr;
802
+ gap: 1rem;
803
+ }
804
+
805
+ .sparkline-stats {
806
+ flex-direction: row;
807
+ flex-wrap: wrap;
808
+ justify-content: space-between;
809
+ }
810
+
811
+ .stat-item {
812
+ flex-direction: column;
813
+ align-items: flex-start;
814
+ gap: 0.125rem;
815
+ min-width: 80px;
816
+ }
817
+ }
818
+
819
+ /* Mobile cards (hidden by default on desktop) */
820
+ .mobile-cards {
821
+ display: none;
822
+ }
823
+
824
+ @media (max-width: 768px) {
825
+ .table-wrapper {
826
+ display: none;
827
+ }
828
+
829
+ .mobile-cards {
830
+ display: flex;
831
+ flex-direction: column;
832
+ gap: 0.75rem;
833
+ }
834
+
835
+ .mobile-card {
836
+ background-color: var(--usage-bg-primary, white);
837
+ border: 1px solid var(--usage-border-default, #d1d9e0);
838
+ border-radius: var(--usage-radius-lg, 8px);
839
+ padding: 1rem;
840
+ transition: all var(--usage-transition-fast, 150ms ease);
841
+ }
842
+
843
+ .mobile-card:hover {
844
+ border-color: var(--usage-border-hover, #b3bcc6);
845
+ box-shadow: var(--usage-shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));
846
+ }
847
+
848
+ .card-header-row {
849
+ display: flex;
850
+ justify-content: space-between;
851
+ align-items: flex-start;
852
+ margin-bottom: 0.75rem;
853
+ gap: 0.5rem;
854
+ }
855
+
856
+ .card-title-area {
857
+ display: flex;
858
+ align-items: center;
859
+ gap: 0.5rem;
860
+ flex: 1;
861
+ min-width: 0;
862
+ }
863
+
864
+ :global(.status-dot) {
865
+ width: 8px;
866
+ height: 8px;
867
+ border-radius: 50%;
868
+ flex-shrink: 0;
869
+ }
870
+
871
+ .card-name {
872
+ font-family: var(--usage-font-mono, 'JetBrains Mono', monospace);
873
+ font-weight: 600;
874
+ font-size: 0.875rem;
875
+ color: var(--usage-text-primary, #1f2328);
876
+ overflow: hidden;
877
+ text-overflow: ellipsis;
878
+ white-space: nowrap;
879
+ }
880
+
881
+ .type-badge-mini {
882
+ flex-shrink: 0;
883
+ font-family: var(--usage-font-sans);
884
+ font-size: 0.625rem;
885
+ font-weight: 600;
886
+ padding: 0.125rem 0.5rem;
887
+ border-radius: 9999px;
888
+ background-color: var(--usage-bg-tertiary, #f0f2f4);
889
+ color: var(--usage-text-secondary, #656d76);
890
+ border: 1px solid var(--usage-border-subtle, #e8ecef);
891
+ }
892
+
893
+ .card-body {
894
+ display: grid;
895
+ grid-template-columns: 1fr 1fr;
896
+ gap: 0.625rem 1rem;
897
+ }
898
+
899
+ .card-metric {
900
+ display: flex;
901
+ flex-direction: column;
902
+ gap: 0.125rem;
903
+ }
904
+
905
+ .card-metric.highlight {
906
+ grid-column: span 2;
907
+ padding: 0.625rem;
908
+ background-color: var(--usage-bg-secondary, #f6f8fa);
909
+ border-radius: var(--usage-radius-md, 6px);
910
+ flex-direction: row;
911
+ justify-content: space-between;
912
+ align-items: center;
913
+ border: 1px solid var(--usage-border-subtle, #e8ecef);
914
+ }
915
+
916
+ .metric-label {
917
+ font-family: var(--usage-font-sans);
918
+ font-size: 0.625rem;
919
+ color: var(--usage-text-muted, #8c959f);
920
+ text-transform: uppercase;
921
+ letter-spacing: 0.05em;
922
+ }
923
+
924
+ .metric-value {
925
+ font-family: var(--usage-font-mono, 'JetBrains Mono', monospace);
926
+ font-size: 0.875rem;
927
+ font-weight: 500;
928
+ color: var(--usage-text-primary, #1f2328);
929
+ }
930
+
931
+ .card-metric.highlight .metric-value {
932
+ font-size: 1.125rem;
933
+ font-weight: 700;
934
+ }
935
+ }
936
+
937
+ /* ================================================
938
+ * Dark Mode Overrides - GitHub Dark Theme
939
+ * ================================================ */
940
+
941
+ :global([data-theme='dark']) .table-wrapper,
942
+ :global(.dark) .table-wrapper {
943
+ background-color: #0d1117;
944
+ border-color: #30363d;
945
+ }
946
+
947
+ :global([data-theme='dark']) thead,
948
+ :global(.dark) thead {
949
+ background-color: #161b22;
950
+ }
951
+
952
+ :global([data-theme='dark']) th,
953
+ :global(.dark) th {
954
+ color: #8b949e;
955
+ border-bottom-color: #30363d;
956
+ }
957
+
958
+ :global([data-theme='dark']) th:hover,
959
+ :global(.dark) th:hover {
960
+ background-color: #21262d;
961
+ color: #e6edf3;
962
+ }
963
+
964
+ :global([data-theme='dark']) th.sorted,
965
+ :global(.dark) th.sorted {
966
+ color: #58a6ff;
967
+ }
968
+
969
+ :global([data-theme='dark']) th.sorted .sort-icon,
970
+ :global(.dark) th.sorted .sort-icon {
971
+ color: #58a6ff;
972
+ }
973
+
974
+ :global([data-theme='dark']) tbody tr,
975
+ :global(.dark) tbody tr {
976
+ border-bottom-color: #21262d;
977
+ }
978
+
979
+ :global([data-theme='dark']) tbody tr:hover,
980
+ :global(.dark) tbody tr:hover {
981
+ background-color: #161b22;
982
+ }
983
+
984
+ :global([data-theme='dark']) td,
985
+ :global(.dark) td {
986
+ color: #e6edf3;
987
+ }
988
+
989
+ :global([data-theme='dark']) .resource-name,
990
+ :global(.dark) .resource-name {
991
+ color: #e6edf3;
992
+ }
993
+
994
+ :global([data-theme='dark']) .type-badge,
995
+ :global(.dark) .type-badge {
996
+ background-color: #21262d;
997
+ border-color: #30363d;
998
+ }
999
+
1000
+ :global([data-theme='dark']) .type-badge:hover,
1001
+ :global(.dark) .type-badge:hover {
1002
+ background-color: #30363d;
1003
+ border-color: #484f58;
1004
+ }
1005
+
1006
+ :global([data-theme='dark']) .type-label,
1007
+ :global(.dark) .type-label {
1008
+ color: var(--badge-accent, #8b949e);
1009
+ }
1010
+
1011
+ :global([data-theme='dark']) .cell-project,
1012
+ :global(.dark) .cell-project {
1013
+ color: #8b949e;
1014
+ }
1015
+
1016
+ :global([data-theme='dark']) .usage-value,
1017
+ :global(.dark) .usage-value {
1018
+ color: #e6edf3;
1019
+ }
1020
+
1021
+ :global([data-theme='dark']) .usage-unit,
1022
+ :global(.dark) .usage-unit {
1023
+ color: #6e7681;
1024
+ }
1025
+
1026
+ :global([data-theme='dark']) .cost-value,
1027
+ :global(.dark) .cost-value {
1028
+ color: #e6edf3;
1029
+ }
1030
+
1031
+ :global([data-theme='dark']) .delta-up,
1032
+ :global(.dark) .delta-up {
1033
+ color: #f85149;
1034
+ background-color: rgba(248, 81, 73, 0.15);
1035
+ }
1036
+
1037
+ :global([data-theme='dark']) .delta-down,
1038
+ :global(.dark) .delta-down {
1039
+ color: #3fb950;
1040
+ background-color: rgba(63, 185, 80, 0.15);
1041
+ }
1042
+
1043
+ /* Dark mode: Project links */
1044
+ :global([data-theme='dark']) .project-link,
1045
+ :global(.dark) .project-link {
1046
+ color: #58a6ff;
1047
+ }
1048
+
1049
+ :global([data-theme='dark']) .project-link:hover,
1050
+ :global(.dark) .project-link:hover {
1051
+ color: #79c0ff;
1052
+ }
1053
+
1054
+ :global([data-theme='dark']) .project-link-mobile,
1055
+ :global(.dark) .project-link-mobile {
1056
+ color: #58a6ff;
1057
+ }
1058
+
1059
+ /* Dark mode: Limit percentages */
1060
+ :global([data-theme='dark']) .limit-ok,
1061
+ :global(.dark) .limit-ok {
1062
+ color: #3fb950;
1063
+ background-color: rgba(63, 185, 80, 0.15);
1064
+ }
1065
+
1066
+ :global([data-theme='dark']) .limit-warning,
1067
+ :global(.dark) .limit-warning {
1068
+ color: #d29922;
1069
+ background-color: rgba(210, 153, 34, 0.15);
1070
+ }
1071
+
1072
+ :global([data-theme='dark']) .limit-high,
1073
+ :global(.dark) .limit-high {
1074
+ color: #db6d28;
1075
+ background-color: rgba(219, 109, 40, 0.15);
1076
+ }
1077
+
1078
+ :global([data-theme='dark']) .limit-critical,
1079
+ :global(.dark) .limit-critical {
1080
+ color: #f85149;
1081
+ background-color: rgba(248, 81, 73, 0.15);
1082
+ }
1083
+
1084
+ :global([data-theme='dark']) .limit-neutral,
1085
+ :global(.dark) .limit-neutral {
1086
+ color: #8b949e;
1087
+ }
1088
+
1089
+ :global([data-theme='dark']) .delta-neutral,
1090
+ :global(.dark) .delta-neutral {
1091
+ color: #6e7681;
1092
+ }
1093
+
1094
+ :global([data-theme='dark']) .delta-new,
1095
+ :global(.dark) .delta-new {
1096
+ color: #58a6ff;
1097
+ background-color: rgba(88, 166, 255, 0.15);
1098
+ }
1099
+
1100
+ :global([data-theme='dark']) .empty-state,
1101
+ :global(.dark) .empty-state {
1102
+ color: #6e7681;
1103
+ }
1104
+
1105
+ :global([data-theme='dark']) .resource-row.expanded,
1106
+ :global(.dark) .resource-row.expanded {
1107
+ background-color: rgba(88, 166, 255, 0.08);
1108
+ }
1109
+
1110
+ :global([data-theme='dark']) .resource-row[data-expandable='true']:hover .resource-name,
1111
+ :global(.dark) .resource-row[data-expandable='true']:hover .resource-name {
1112
+ color: #58a6ff;
1113
+ }
1114
+
1115
+ :global([data-theme='dark']) .expansion-row,
1116
+ :global(.dark) .expansion-row {
1117
+ background-color: #161b22;
1118
+ }
1119
+
1120
+ :global([data-theme='dark']) .expansion-cell,
1121
+ :global(.dark) .expansion-cell {
1122
+ border-bottom-color: #30363d;
1123
+ }
1124
+
1125
+ :global([data-theme='dark']) .sparkline-loading,
1126
+ :global(.dark) .sparkline-loading {
1127
+ color: #8b949e;
1128
+ }
1129
+
1130
+ :global([data-theme='dark']) .sparkline-title,
1131
+ :global(.dark) .sparkline-title {
1132
+ color: #8b949e;
1133
+ }
1134
+
1135
+ :global([data-theme='dark']) .sparkline-current,
1136
+ :global(.dark) .sparkline-current {
1137
+ color: #e6edf3;
1138
+ }
1139
+
1140
+ :global([data-theme='dark']) .sparkline-stats,
1141
+ :global(.dark) .sparkline-stats {
1142
+ background-color: #0d1117;
1143
+ border-color: #30363d;
1144
+ }
1145
+
1146
+ :global([data-theme='dark']) .stat-label,
1147
+ :global(.dark) .stat-label {
1148
+ color: #6e7681;
1149
+ }
1150
+
1151
+ :global([data-theme='dark']) .stat-value,
1152
+ :global(.dark) .stat-value {
1153
+ color: #e6edf3;
1154
+ }
1155
+
1156
+ :global([data-theme='dark']) .cf-dashboard-link,
1157
+ :global(.dark) .cf-dashboard-link {
1158
+ background-color: rgba(154, 103, 0, 0.2);
1159
+ border-color: #9a6700;
1160
+ color: #d29922;
1161
+ }
1162
+
1163
+ :global([data-theme='dark']) .cf-dashboard-link:hover,
1164
+ :global(.dark) .cf-dashboard-link:hover {
1165
+ background-color: rgba(154, 103, 0, 0.3);
1166
+ }
1167
+
1168
+ :global([data-theme='dark']) :global(.status-indicator),
1169
+ :global(.dark) :global(.status-indicator) {
1170
+ box-shadow: 0 0 0 2px #0d1117;
1171
+ }
1172
+
1173
+ /* Mobile cards dark mode */
1174
+ @media (max-width: 768px) {
1175
+ :global([data-theme='dark']) .mobile-card,
1176
+ :global(.dark) .mobile-card {
1177
+ background-color: #0d1117;
1178
+ border-color: #30363d;
1179
+ }
1180
+
1181
+ :global([data-theme='dark']) .mobile-card:hover,
1182
+ :global(.dark) .mobile-card:hover {
1183
+ border-color: #484f58;
1184
+ }
1185
+
1186
+ :global([data-theme='dark']) .card-name,
1187
+ :global(.dark) .card-name {
1188
+ color: #e6edf3;
1189
+ }
1190
+
1191
+ :global([data-theme='dark']) .type-badge-mini,
1192
+ :global(.dark) .type-badge-mini {
1193
+ background-color: #21262d;
1194
+ border-color: #30363d;
1195
+ color: #8b949e;
1196
+ }
1197
+
1198
+ :global([data-theme='dark']) .card-metric.highlight,
1199
+ :global(.dark) .card-metric.highlight {
1200
+ background-color: #161b22;
1201
+ border-color: #30363d;
1202
+ }
1203
+
1204
+ :global([data-theme='dark']) .metric-label,
1205
+ :global(.dark) .metric-label {
1206
+ color: #6e7681;
1207
+ }
1208
+
1209
+ :global([data-theme='dark']) .metric-value,
1210
+ :global(.dark) .metric-value {
1211
+ color: #e6edf3;
1212
+ }
1213
+ }
1214
+ </style>
1215
+
1216
+ <script define:vars={{ tableId, sortColumn: sortColumn, sortDirection: sortDirection }}>
1217
+ // Sorting functionality
1218
+ document.addEventListener('DOMContentLoaded', () => {
1219
+ const table = document.querySelector(`[data-table-id="${tableId}"]`);
1220
+ if (!table) return;
1221
+
1222
+ const headers = table.querySelectorAll('th.sortable');
1223
+ let currentSort = { column: sortColumn, direction: sortDirection };
1224
+
1225
+ // Get value from row for sorting
1226
+ function getSortValue(row, column, type) {
1227
+ const cell =
1228
+ row.querySelector(`[data-label="${getLabel(column)}"]`) ||
1229
+ row.querySelector(`.cell-${column}`);
1230
+ if (!cell) return null;
1231
+
1232
+ if (column === 'usage' || column === 'costCurrent' || column === 'costDeltaPct') {
1233
+ const value = cell.getAttribute('data-value');
1234
+ if (value === 'NEW') return Infinity; // NEW items sort to top
1235
+ if (value === null || value === 'null') return -Infinity;
1236
+ return parseFloat(value) || 0;
1237
+ }
1238
+
1239
+ if (type === 'number') {
1240
+ return parseFloat(cell.textContent.replace(/[^0-9.-]/g, '')) || 0;
1241
+ }
1242
+
1243
+ return cell.textContent.trim().toLowerCase();
1244
+ }
1245
+
1246
+ // Get label for column
1247
+ function getLabel(column) {
1248
+ const labels = {
1249
+ name: 'Name',
1250
+ type: 'Type',
1251
+ project: 'Project',
1252
+ usage: 'Usage',
1253
+ costCurrent: 'Cost',
1254
+ costDeltaPct: 'Change',
1255
+ status: 'Status',
1256
+ };
1257
+ return labels[column] || column;
1258
+ }
1259
+
1260
+ // Sort table rows
1261
+ function sortTable(column, direction) {
1262
+ const tbody = table.querySelector('tbody');
1263
+ const rows = Array.from(tbody.querySelectorAll('tr.resource-row'));
1264
+ const type =
1265
+ table.querySelector(`th[data-sort="${column}"]`)?.getAttribute('data-type') || 'string';
1266
+
1267
+ rows.sort((a, b) => {
1268
+ const aVal = getSortValue(a, column, type);
1269
+ const bVal = getSortValue(b, column, type);
1270
+
1271
+ if (aVal === null) return 1;
1272
+ if (bVal === null) return -1;
1273
+
1274
+ let comparison = 0;
1275
+ if (type === 'number') {
1276
+ comparison = aVal - bVal;
1277
+ } else {
1278
+ comparison = aVal.localeCompare(bVal);
1279
+ }
1280
+
1281
+ return direction === 'asc' ? comparison : -comparison;
1282
+ });
1283
+
1284
+ // Reorder DOM
1285
+ rows.forEach((row) => tbody.appendChild(row));
1286
+
1287
+ // Update header classes
1288
+ headers.forEach((h) => {
1289
+ h.classList.remove('sorted', 'asc', 'desc');
1290
+ h.setAttribute('aria-sort', 'none');
1291
+ });
1292
+
1293
+ const activeHeader = table.querySelector(`th[data-sort="${column}"]`);
1294
+ if (activeHeader) {
1295
+ activeHeader.classList.add('sorted', direction);
1296
+ activeHeader.setAttribute('aria-sort', direction === 'asc' ? 'ascending' : 'descending');
1297
+ }
1298
+
1299
+ currentSort = { column, direction };
1300
+ }
1301
+
1302
+ // Click handler for headers
1303
+ headers.forEach((header) => {
1304
+ header.addEventListener('click', () => {
1305
+ const column = header.getAttribute('data-sort');
1306
+ const newDirection =
1307
+ currentSort.column === column && currentSort.direction === 'desc' ? 'asc' : 'desc';
1308
+ sortTable(column, newDirection);
1309
+ });
1310
+
1311
+ // Keyboard support
1312
+ header.addEventListener('keydown', (e) => {
1313
+ if (e.key === 'Enter' || e.key === ' ') {
1314
+ e.preventDefault();
1315
+ header.click();
1316
+ }
1317
+ });
1318
+ });
1319
+
1320
+ // Row click for expansion (delegated to parent)
1321
+ const tbody = table.querySelector('tbody');
1322
+ tbody.addEventListener('click', (e) => {
1323
+ const row = e.target.closest('tr[data-expandable="true"]');
1324
+ if (row) {
1325
+ const resourceId = row.getAttribute('data-resource-id');
1326
+ const event = new CustomEvent('resource-expand', {
1327
+ bubbles: true,
1328
+ detail: { resourceId, row },
1329
+ });
1330
+ table.dispatchEvent(event);
1331
+ }
1332
+ });
1333
+
1334
+ tbody.addEventListener('keydown', (e) => {
1335
+ if (e.key === 'Enter' || e.key === ' ') {
1336
+ const row = e.target.closest('tr[data-expandable="true"]');
1337
+ if (row) {
1338
+ e.preventDefault();
1339
+ row.click();
1340
+ }
1341
+ }
1342
+ });
1343
+
1344
+ // ========== Row Expansion with Sparklines (Task-17.11) ==========
1345
+ const expandedRows = new Map();
1346
+ const sparklineCache = new Map();
1347
+
1348
+ table.addEventListener('resource-expand', async (e) => {
1349
+ const { resourceId, row } = e.detail;
1350
+
1351
+ // If already expanded, collapse it
1352
+ if (expandedRows.has(resourceId)) {
1353
+ const panel = expandedRows.get(resourceId);
1354
+ panel.remove();
1355
+ expandedRows.delete(resourceId);
1356
+ row.classList.remove('expanded');
1357
+ return;
1358
+ }
1359
+
1360
+ // Collapse any other expanded rows
1361
+ expandedRows.forEach((panel, id) => {
1362
+ panel.remove();
1363
+ const prevRow = tbody.querySelector(`tr[data-resource-id="${id}"]`);
1364
+ if (prevRow) prevRow.classList.remove('expanded');
1365
+ });
1366
+ expandedRows.clear();
1367
+
1368
+ // Create expansion panel
1369
+ const panel = createExpansionPanel(resourceId, row);
1370
+ row.after(panel);
1371
+ row.classList.add('expanded');
1372
+ expandedRows.set(resourceId, panel);
1373
+
1374
+ // Load sparkline data
1375
+ await loadSparklineData(resourceId, panel);
1376
+ });
1377
+
1378
+ function createExpansionPanel(resourceId, row) {
1379
+ const tr = document.createElement('tr');
1380
+ tr.className = 'expansion-row';
1381
+ tr.setAttribute('data-expansion-for', resourceId);
1382
+
1383
+ const td = document.createElement('td');
1384
+ td.setAttribute('colspan', '7');
1385
+ td.className = 'expansion-cell';
1386
+
1387
+ const content = document.createElement('div');
1388
+ content.className = 'expansion-content';
1389
+
1390
+ // Loading state
1391
+ const loading = document.createElement('div');
1392
+ loading.className = 'sparkline-loading';
1393
+ loading.textContent = 'Loading usage history...';
1394
+ content.appendChild(loading);
1395
+
1396
+ td.appendChild(content);
1397
+ tr.appendChild(td);
1398
+ return tr;
1399
+ }
1400
+
1401
+ async function loadSparklineData(resourceId, panel) {
1402
+ const content = panel.querySelector('.expansion-content');
1403
+ if (!content) return;
1404
+
1405
+ // Check cache first
1406
+ if (sparklineCache.has(resourceId)) {
1407
+ renderSparklines(content, sparklineCache.get(resourceId));
1408
+ return;
1409
+ }
1410
+
1411
+ try {
1412
+ // Fetch historical data from API
1413
+ const resourceType =
1414
+ panel.previousElementSibling?.getAttribute('data-resource-type') || 'worker';
1415
+ const resourceName =
1416
+ panel.previousElementSibling?.querySelector('.resource-name')?.textContent || '';
1417
+
1418
+ const response = await fetch(
1419
+ `/api/brand-copilot/usage/history?resourceId=${encodeURIComponent(resourceId)}&type=${resourceType}&name=${encodeURIComponent(resourceName)}&days=30`
1420
+ );
1421
+
1422
+ if (!response.ok) {
1423
+ // Generate mock data for demo purposes if API not available
1424
+ const mockData = generateMockHistoryData();
1425
+ sparklineCache.set(resourceId, mockData);
1426
+ renderSparklines(content, mockData);
1427
+ return;
1428
+ }
1429
+
1430
+ const data = await response.json();
1431
+ sparklineCache.set(resourceId, data);
1432
+ renderSparklines(content, data);
1433
+ } catch (err) {
1434
+ // Fallback to mock data if API fails
1435
+ const mockData = generateMockHistoryData();
1436
+ sparklineCache.set(resourceId, mockData);
1437
+ renderSparklines(content, mockData);
1438
+ }
1439
+ }
1440
+
1441
+ function generateMockHistoryData() {
1442
+ // Generate 30 days of mock data with some variation
1443
+ const days = 30;
1444
+ const baseValue = Math.random() * 1000 + 100;
1445
+ const usageData = [];
1446
+ const costData = [];
1447
+
1448
+ for (let i = 0; i < days; i++) {
1449
+ const variation = (Math.random() - 0.5) * 0.3; // ±15% variation
1450
+ usageData.push(Math.max(0, baseValue * (1 + variation)));
1451
+ costData.push(Math.max(0, baseValue * 0.0001 * (1 + variation)));
1452
+ }
1453
+
1454
+ return {
1455
+ usage: usageData,
1456
+ cost: costData,
1457
+ labels: Array.from({ length: days }, (_, i) => {
1458
+ const d = new Date();
1459
+ d.setDate(d.getDate() - (days - 1 - i));
1460
+ return d.toISOString().slice(5, 10);
1461
+ }),
1462
+ };
1463
+ }
1464
+
1465
+ // ========== Cloudflare Deep Links (Task-17.19) ==========
1466
+ const CLOUDFLARE_ACCOUNT_ID = '55a0bf6d1396d90cbf9dcbf30fceeb14';
1467
+
1468
+ function getCloudflareUrl(resourceType, resourceName) {
1469
+ const baseUrl = `https://dash.cloudflare.com/${CLOUDFLARE_ACCOUNT_ID}`;
1470
+
1471
+ switch (resourceType) {
1472
+ case 'worker':
1473
+ return `${baseUrl}/workers/services/view/${resourceName}/production/observability/logs`;
1474
+ case 'd1':
1475
+ return `${baseUrl}/d1/database/${resourceName}`;
1476
+ case 'kv':
1477
+ return `${baseUrl}/workers/kv/namespaces`;
1478
+ case 'r2':
1479
+ return `${baseUrl}/r2/overview/buckets/${resourceName}`;
1480
+ case 'vectorize':
1481
+ return `${baseUrl}/vectorize`;
1482
+ case 'pages':
1483
+ return `${baseUrl}/pages/view/${resourceName}`;
1484
+ case 'do':
1485
+ return `${baseUrl}/workers/services`;
1486
+ case 'ai-gateway':
1487
+ return `${baseUrl}/ai/ai-gateway`;
1488
+ case 'queues':
1489
+ return `${baseUrl}/queues`;
1490
+ case 'workflows':
1491
+ return `${baseUrl}/workflows`;
1492
+ default:
1493
+ return `${baseUrl}/workers-and-pages`;
1494
+ }
1495
+ }
1496
+
1497
+ function createCloudflareLink(resourceType, resourceName) {
1498
+ const wrapper = document.createElement('div');
1499
+ wrapper.className = 'cf-link-section';
1500
+
1501
+ const link = document.createElement('a');
1502
+ link.href = getCloudflareUrl(resourceType, resourceName);
1503
+ link.target = '_blank';
1504
+ link.rel = 'noopener noreferrer';
1505
+ link.className = 'cf-dashboard-link';
1506
+ link.title = 'View in Cloudflare Dashboard';
1507
+
1508
+ // CF Logo SVG
1509
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1510
+ svg.setAttribute('width', '16');
1511
+ svg.setAttribute('height', '16');
1512
+ svg.setAttribute('viewBox', '0 0 128 128');
1513
+ svg.setAttribute('fill', 'currentColor');
1514
+
1515
+ const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1516
+ path1.setAttribute(
1517
+ 'd',
1518
+ 'M99.4 76.4c1.5-.5 2.4-2.1 2.3-3.8-.7-5.9-4.5-11.1-9.9-13.2-2.3-.9-4.8-1.2-7.3-.9l-58.2 1.1c-.3 0-.6.2-.8.4-.2.2-.3.5-.3.8v.8c.1.3.2.6.4.8.2.2.5.4.8.4l57.8-1.1c4.5-.4 8.8 2.5 10.1 6.8.2.6.3 1.3.4 2-.1.4.2.8.6 1l4.1 4.9z'
1519
+ );
1520
+ svg.appendChild(path1);
1521
+
1522
+ const path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1523
+ path2.setAttribute(
1524
+ 'd',
1525
+ 'M106.8 81.3l-3.7-4.3c-1.2 5.5-5.5 9.8-11.1 10.9-1.5.3-3 .3-4.5.1l-62.2.3c-.3 0-.6.2-.8.4-.2.2-.3.5-.3.8v.8c.1.3.2.6.4.8.2.2.5.4.8.4l61.7-.3c8.2.6 15.5-5.6 16.4-13.8l3.3 4.9z'
1526
+ );
1527
+ svg.appendChild(path2);
1528
+
1529
+ link.appendChild(svg);
1530
+
1531
+ const text = document.createElement('span');
1532
+ text.textContent = 'Open in Cloudflare';
1533
+ link.appendChild(text);
1534
+
1535
+ wrapper.appendChild(link);
1536
+ return wrapper;
1537
+ }
1538
+
1539
+ function renderSparklines(content, data) {
1540
+ // Clear loading state
1541
+ while (content.firstChild) {
1542
+ content.removeChild(content.firstChild);
1543
+ }
1544
+
1545
+ // Get resource info from parent row
1546
+ const expansionRow = content.closest('.expansion-row');
1547
+ const resourceRow = expansionRow?.previousElementSibling;
1548
+ const resourceType = resourceRow?.getAttribute('data-resource-type') || 'worker';
1549
+ const resourceName = resourceRow?.querySelector('.resource-name')?.textContent || '';
1550
+
1551
+ // Create sparkline container
1552
+ const container = document.createElement('div');
1553
+ container.className = 'sparklines-container';
1554
+
1555
+ // Usage sparkline
1556
+ const usageSection = createSparklineSection(
1557
+ 'Usage (30 days)',
1558
+ data.usage,
1559
+ data.labels,
1560
+ '#3b82f6'
1561
+ );
1562
+ container.appendChild(usageSection);
1563
+
1564
+ // Cost sparkline
1565
+ const costSection = createSparklineSection(
1566
+ 'Cost (30 days)',
1567
+ data.cost,
1568
+ data.labels,
1569
+ '#10b981',
1570
+ '$'
1571
+ );
1572
+ container.appendChild(costSection);
1573
+
1574
+ // Stats + CF link container
1575
+ const rightSection = document.createElement('div');
1576
+ rightSection.className = 'sparkline-right-section';
1577
+
1578
+ // Stats summary
1579
+ const stats = createStatsSummary(data);
1580
+ rightSection.appendChild(stats);
1581
+
1582
+ // Cloudflare deep link (Task-17.19)
1583
+ const cfLink = createCloudflareLink(resourceType, resourceName);
1584
+ rightSection.appendChild(cfLink);
1585
+
1586
+ container.appendChild(rightSection);
1587
+
1588
+ content.appendChild(container);
1589
+ }
1590
+
1591
+ function createSparklineSection(title, values, labels, colour, prefix = '') {
1592
+ const section = document.createElement('div');
1593
+ section.className = 'sparkline-section';
1594
+
1595
+ const header = document.createElement('div');
1596
+ header.className = 'sparkline-header';
1597
+
1598
+ const titleEl = document.createElement('span');
1599
+ titleEl.className = 'sparkline-title';
1600
+ titleEl.textContent = title;
1601
+ header.appendChild(titleEl);
1602
+
1603
+ const valueEl = document.createElement('span');
1604
+ valueEl.className = 'sparkline-current';
1605
+ const current = values[values.length - 1];
1606
+ valueEl.textContent =
1607
+ prefix + (current >= 1000 ? (current / 1000).toFixed(1) + 'K' : current.toFixed(2));
1608
+ header.appendChild(valueEl);
1609
+
1610
+ section.appendChild(header);
1611
+
1612
+ // Create SVG sparkline
1613
+ const svg = createSparklineSVG(values, colour);
1614
+ section.appendChild(svg);
1615
+
1616
+ return section;
1617
+ }
1618
+
1619
+ function createSparklineSVG(values, colour) {
1620
+ const width = 200;
1621
+ const height = 40;
1622
+ const padding = 2;
1623
+
1624
+ const min = Math.min(...values);
1625
+ const max = Math.max(...values);
1626
+ const range = max - min || 1;
1627
+
1628
+ const points = values.map((v, i) => {
1629
+ const x = padding + ((width - 2 * padding) * i) / (values.length - 1);
1630
+ const y = height - padding - ((v - min) / range) * (height - 2 * padding);
1631
+ return `${x},${y}`;
1632
+ });
1633
+
1634
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1635
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
1636
+ svg.setAttribute('class', 'sparkline-svg');
1637
+ svg.setAttribute('aria-hidden', 'true');
1638
+
1639
+ // Create gradient
1640
+ const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
1641
+ const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
1642
+ gradient.setAttribute('id', 'sparkline-gradient-' + Math.random().toString(36).slice(2));
1643
+ gradient.setAttribute('x1', '0%');
1644
+ gradient.setAttribute('y1', '0%');
1645
+ gradient.setAttribute('x2', '0%');
1646
+ gradient.setAttribute('y2', '100%');
1647
+
1648
+ const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
1649
+ stop1.setAttribute('offset', '0%');
1650
+ stop1.setAttribute('stop-color', colour);
1651
+ stop1.setAttribute('stop-opacity', '0.2');
1652
+
1653
+ const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
1654
+ stop2.setAttribute('offset', '100%');
1655
+ stop2.setAttribute('stop-color', colour);
1656
+ stop2.setAttribute('stop-opacity', '0');
1657
+
1658
+ gradient.appendChild(stop1);
1659
+ gradient.appendChild(stop2);
1660
+ defs.appendChild(gradient);
1661
+ svg.appendChild(defs);
1662
+
1663
+ // Area fill
1664
+ const areaPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1665
+ const areaD = `M${padding},${height - padding} L${points.join(' L')} L${width - padding},${height - padding} Z`;
1666
+ areaPath.setAttribute('d', areaD);
1667
+ areaPath.setAttribute('fill', `url(#${gradient.id})`);
1668
+ svg.appendChild(areaPath);
1669
+
1670
+ // Line
1671
+ const linePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1672
+ linePath.setAttribute('d', `M${points.join(' L')}`);
1673
+ linePath.setAttribute('fill', 'none');
1674
+ linePath.setAttribute('stroke', colour);
1675
+ linePath.setAttribute('stroke-width', '2');
1676
+ linePath.setAttribute('stroke-linecap', 'round');
1677
+ linePath.setAttribute('stroke-linejoin', 'round');
1678
+ svg.appendChild(linePath);
1679
+
1680
+ // End dot
1681
+ const lastPoint = points[points.length - 1].split(',');
1682
+ const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
1683
+ dot.setAttribute('cx', lastPoint[0]);
1684
+ dot.setAttribute('cy', lastPoint[1]);
1685
+ dot.setAttribute('r', '3');
1686
+ dot.setAttribute('fill', colour);
1687
+ svg.appendChild(dot);
1688
+
1689
+ return svg;
1690
+ }
1691
+
1692
+ function createStatsSummary(data) {
1693
+ const stats = document.createElement('div');
1694
+ stats.className = 'sparkline-stats';
1695
+
1696
+ const usageMin = Math.min(...data.usage);
1697
+ const usageMax = Math.max(...data.usage);
1698
+ const usageAvg = data.usage.reduce((a, b) => a + b, 0) / data.usage.length;
1699
+
1700
+ const costMin = Math.min(...data.cost);
1701
+ const costMax = Math.max(...data.cost);
1702
+ const costTotal = data.cost.reduce((a, b) => a + b, 0);
1703
+
1704
+ const statItems = [
1705
+ { label: 'Avg Usage', value: formatStatValue(usageAvg) },
1706
+ { label: 'Peak Usage', value: formatStatValue(usageMax) },
1707
+ { label: 'Min Usage', value: formatStatValue(usageMin) },
1708
+ { label: '30d Total Cost', value: '$' + costTotal.toFixed(4) },
1709
+ ];
1710
+
1711
+ statItems.forEach((item) => {
1712
+ const stat = document.createElement('div');
1713
+ stat.className = 'stat-item';
1714
+
1715
+ const label = document.createElement('span');
1716
+ label.className = 'stat-label';
1717
+ label.textContent = item.label;
1718
+ stat.appendChild(label);
1719
+
1720
+ const value = document.createElement('span');
1721
+ value.className = 'stat-value';
1722
+ value.textContent = item.value;
1723
+ stat.appendChild(value);
1724
+
1725
+ stats.appendChild(stat);
1726
+ });
1727
+
1728
+ return stats;
1729
+ }
1730
+
1731
+ function formatStatValue(num) {
1732
+ if (num >= 1000000) return (num / 1000000).toFixed(2) + 'M';
1733
+ if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
1734
+ return num.toFixed(2);
1735
+ }
1736
+ });
1737
+ </script>