@littlebearapps/platform-admin-sdk 2.0.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 (185) hide show
  1. package/README.md +4 -7
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +206 -4
  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/DigestStats.tsx +151 -0
  9. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  10. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  11. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  12. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  13. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  14. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  15. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  16. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  17. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  18. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  19. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  20. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  21. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  22. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  23. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  24. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  25. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  26. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  27. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  28. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  29. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  30. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  31. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  32. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  33. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  34. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  35. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  36. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  37. package/templates/full/dashboard/src/pages/map.astro +561 -0
  38. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  39. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  40. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  41. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  42. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  43. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  44. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  45. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  46. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  47. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  48. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  49. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  50. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  51. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  52. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  53. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  54. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  55. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  56. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  57. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  58. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  59. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  60. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  61. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  62. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  63. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  64. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  65. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  66. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  67. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  68. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  69. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  70. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  71. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  72. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  73. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  74. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  75. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  76. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  77. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  78. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  79. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  80. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  81. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  82. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  83. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  84. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  85. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  86. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  87. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  88. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  89. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  90. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  91. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  92. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  93. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  94. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  95. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  96. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  97. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  98. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  99. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  100. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  101. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  102. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  103. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  104. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  105. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  106. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  107. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  108. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  109. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  110. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  111. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  112. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  113. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  114. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  115. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  116. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  117. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  118. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  119. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  120. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  121. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  122. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  123. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  124. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  125. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  126. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  127. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  128. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  129. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  130. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  131. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  132. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  133. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  134. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  135. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  136. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  137. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  138. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  139. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  140. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  141. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  142. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  143. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  144. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  145. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  146. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  147. package/templates/shared/tests/unit/billing.test.ts +331 -0
  148. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  149. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  150. package/templates/shared/tests/unit/control.test.ts +226 -0
  151. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  152. package/templates/shared/tests/unit/economics.test.ts +365 -0
  153. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  154. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  155. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  156. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  157. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  158. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  159. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  160. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  161. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  162. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  163. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  164. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  165. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  166. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  167. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  168. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  169. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  170. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  171. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  172. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  173. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  174. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  175. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  176. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  177. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  178. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  179. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  180. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  181. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
  182. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  183. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  184. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  185. package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
@@ -0,0 +1,385 @@
1
+ /**
2
+ * UsageTable Component
3
+ * Industrial Command Center aesthetic - dense rows, sparklines, progress bars
4
+ */
5
+
6
+ import { useMemo, useState } from 'react';
7
+ import {
8
+ useReactTable,
9
+ getCoreRowModel,
10
+ getSortedRowModel,
11
+ flexRender,
12
+ type ColumnDef,
13
+ type SortingState,
14
+ } from '@tanstack/react-table';
15
+ import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
16
+ import { clsx } from 'clsx';
17
+ import {
18
+ type ProjectTableRow,
19
+ type ProjectStatus,
20
+ type UsageRow,
21
+ type HealthStatus,
22
+ CB_COLORS,
23
+ HEALTH_COLORS,
24
+ } from './types';
25
+ import { StatusBadge } from './StatusBadge';
26
+
27
+ interface UsageTableProps {
28
+ data: UsageRow[];
29
+ statusMap: Record<string, ProjectStatus>;
30
+ loading?: boolean;
31
+ }
32
+
33
+ /**
34
+ * Format number with K/M suffix
35
+ */
36
+ function formatNumber(value: number): string {
37
+ if (isNaN(value) || value === 0) return '0';
38
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
39
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
40
+ return value.toFixed(0);
41
+ }
42
+
43
+ /**
44
+ * Format currency
45
+ */
46
+ function formatCurrency(value: number): string {
47
+ return `$${value.toFixed(2)}`;
48
+ }
49
+
50
+ /**
51
+ * Mini sparkline bar chart
52
+ */
53
+ function Sparkline({ data, className }: { data: number[]; className?: string }) {
54
+ if (!data.length) return null;
55
+
56
+ const max = Math.max(...data, 1);
57
+ const normalised = data.map((v) => (v / max) * 100);
58
+
59
+ return (
60
+ <div className={clsx('flex items-end gap-0.5 h-4', className)}>
61
+ {normalised.map((height, i) => (
62
+ <div
63
+ key={i}
64
+ className="w-1 bg-slate-600 rounded-sm transition-all"
65
+ style={{ height: `${Math.max(height, 5)}%` }}
66
+ />
67
+ ))}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Progress bar with threshold colours
74
+ */
75
+ function SpendBar({ percentage, className }: { percentage: number; className?: string }) {
76
+ const getColour = (pct: number): string => {
77
+ if (pct >= 90) return 'bg-rose-500';
78
+ if (pct >= 75) return 'bg-amber-500';
79
+ if (pct >= 50) return 'bg-yellow-500';
80
+ return 'bg-emerald-500';
81
+ };
82
+
83
+ return (
84
+ <div className={clsx('w-full bg-slate-800 rounded-sm h-2 overflow-hidden', className)}>
85
+ <div
86
+ className={clsx('h-full rounded-sm transition-all', getColour(percentage))}
87
+ style={{ width: `${Math.min(percentage, 100)}%` }}
88
+ />
89
+ </div>
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Circuit breaker status dot
95
+ */
96
+ function CBDot({ state }: { state: 'active' | 'tripped' }) {
97
+ return (
98
+ <span
99
+ className={clsx(
100
+ 'inline-block w-3 h-3 rounded-full',
101
+ CB_COLORS[state],
102
+ state === 'tripped' && 'animate-pulse'
103
+ )}
104
+ title={state === 'active' ? 'Circuit breaker active' : 'Circuit breaker tripped'}
105
+ />
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Determine health status from lastSeen timestamp
111
+ */
112
+ function getHealthStatus(lastSeen?: string): HealthStatus {
113
+ if (!lastSeen) return 'unknown';
114
+
115
+ const lastSeenDate = new Date(lastSeen);
116
+ const hourAgo = Date.now() - 60 * 60 * 1000;
117
+
118
+ return lastSeenDate.getTime() > hourAgo ? 'online' : 'idle';
119
+ }
120
+
121
+ /**
122
+ * Health indicator dot with tooltip
123
+ */
124
+ function HealthDot({ lastSeen }: { lastSeen?: string }) {
125
+ const status = getHealthStatus(lastSeen);
126
+ const { dot, text } = HEALTH_COLORS[status];
127
+
128
+ const label = status === 'online' ? 'Online' : status === 'idle' ? 'Idle' : 'Unknown';
129
+ const tooltip = lastSeen
130
+ ? `Last seen: ${new Date(lastSeen).toLocaleString()}`
131
+ : 'No heartbeat received';
132
+
133
+ return (
134
+ <span className={clsx('flex items-center gap-1', text)} title={tooltip}>
135
+ <span>{dot}</span>
136
+ <span className="text-xs hidden sm:inline">{label}</span>
137
+ </span>
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Transform usage data + status map into table rows
143
+ */
144
+ function transformToTableRows(
145
+ usageData: UsageRow[],
146
+ statusMap: Record<string, ProjectStatus>
147
+ ): ProjectTableRow[] {
148
+ // Group usage by project
149
+ const projectActivity = new Map<string, { total: number; trend: number[] }>();
150
+
151
+ for (const row of usageData) {
152
+ const existing = projectActivity.get(row.project_id);
153
+ const activity =
154
+ (row.d1_reads || 0) +
155
+ (row.d1_writes || 0) +
156
+ (row.kv_reads || 0) +
157
+ (row.kv_writes || 0) +
158
+ (row.workers_requests || 0);
159
+
160
+ if (existing) {
161
+ existing.total += activity;
162
+ existing.trend.push(activity);
163
+ } else {
164
+ projectActivity.set(row.project_id, { total: activity, trend: [activity] });
165
+ }
166
+ }
167
+
168
+ // Combine with status data
169
+ const rows: ProjectTableRow[] = [];
170
+
171
+ // First add projects from status map
172
+ for (const [projectId, status] of Object.entries(statusMap)) {
173
+ const activityData = projectActivity.get(projectId) || { total: 0, trend: [] };
174
+ rows.push({
175
+ id: projectId,
176
+ name: projectId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
177
+ status: status.status,
178
+ activity: activityData.total,
179
+ activityTrend: activityData.trend.slice(-6), // Last 6 data points
180
+ spend: status.spend,
181
+ cap: status.cap,
182
+ percentage: status.percentage,
183
+ circuitBreaker: status.circuitBreaker,
184
+ lastSeen: status.lastSeen,
185
+ });
186
+ }
187
+
188
+ // Add any projects from usage data not in status map
189
+ for (const [projectId, activityData] of projectActivity) {
190
+ if (!statusMap[projectId]) {
191
+ rows.push({
192
+ id: projectId,
193
+ name: projectId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
194
+ status: 'RUN',
195
+ activity: activityData.total,
196
+ activityTrend: activityData.trend.slice(-6),
197
+ spend: 0,
198
+ cap: 100,
199
+ percentage: 0,
200
+ circuitBreaker: 'active',
201
+ });
202
+ }
203
+ }
204
+
205
+ return rows;
206
+ }
207
+
208
+ /**
209
+ * Sorting indicator
210
+ */
211
+ function SortIndicator({ isSorted }: { isSorted: false | 'asc' | 'desc' }) {
212
+ if (!isSorted) {
213
+ return <ChevronsUpDown className="w-3 h-3 text-slate-600" />;
214
+ }
215
+ return isSorted === 'asc' ? (
216
+ <ChevronUp className="w-3 h-3 text-slate-400" />
217
+ ) : (
218
+ <ChevronDown className="w-3 h-3 text-slate-400" />
219
+ );
220
+ }
221
+
222
+ /**
223
+ * Loading skeleton
224
+ */
225
+ function TableSkeleton() {
226
+ return (
227
+ <div className="bg-slate-900 rounded-sm border border-slate-800 overflow-hidden">
228
+ <div className="animate-pulse">
229
+ {/* Header */}
230
+ <div className="h-10 bg-slate-800/50 border-b border-slate-700" />
231
+ {/* Rows */}
232
+ {[...Array(5)].map((_, i) => (
233
+ <div key={i} className="h-12 border-b border-slate-800 flex items-center px-4 gap-4">
234
+ <div className="h-4 bg-slate-800 rounded w-24" />
235
+ <div className="h-4 bg-slate-800 rounded w-16" />
236
+ <div className="flex-1 h-2 bg-slate-800 rounded" />
237
+ <div className="h-3 bg-slate-800 rounded-full w-3" />
238
+ </div>
239
+ ))}
240
+ </div>
241
+ </div>
242
+ );
243
+ }
244
+
245
+ export function UsageTable({ data, statusMap, loading = false }: UsageTableProps) {
246
+ const [sorting, setSorting] = useState<SortingState>([{ id: 'percentage', desc: true }]);
247
+
248
+ const tableData = useMemo(() => transformToTableRows(data, statusMap), [data, statusMap]);
249
+
250
+ const columns = useMemo<ColumnDef<ProjectTableRow>[]>(
251
+ () => [
252
+ {
253
+ accessorKey: 'name',
254
+ header: 'Project',
255
+ cell: ({ row }) => (
256
+ <div className="flex items-center gap-2">
257
+ <StatusBadge status={row.original.status} size="sm" showLabel={false} />
258
+ <span className="font-mono text-sm text-slate-200 truncate max-w-[150px]">
259
+ {row.original.name}
260
+ </span>
261
+ </div>
262
+ ),
263
+ },
264
+ {
265
+ accessorKey: 'activity',
266
+ header: 'Activity',
267
+ cell: ({ row }) => (
268
+ <div className="flex items-center gap-3">
269
+ {/* Hide sparkline on mobile */}
270
+ <Sparkline data={row.original.activityTrend} className="hidden sm:flex" />
271
+ <span className="font-mono text-sm text-slate-300">
272
+ {formatNumber(row.original.activity)}
273
+ </span>
274
+ </div>
275
+ ),
276
+ },
277
+ {
278
+ accessorKey: 'percentage',
279
+ header: 'Spend',
280
+ cell: ({ row }) => (
281
+ <div className="flex items-center gap-3 min-w-[120px]">
282
+ <SpendBar percentage={row.original.percentage} className="flex-1" />
283
+ <span className="font-mono text-xs text-slate-400 w-12 text-right">
284
+ {row.original.percentage.toFixed(0)}%
285
+ </span>
286
+ </div>
287
+ ),
288
+ },
289
+ {
290
+ accessorKey: 'spend',
291
+ header: 'Cost',
292
+ cell: ({ row }) => (
293
+ <span className="font-mono text-sm text-slate-300">
294
+ {formatCurrency(row.original.spend)}
295
+ </span>
296
+ ),
297
+ },
298
+ {
299
+ accessorKey: 'circuitBreaker',
300
+ header: 'CB',
301
+ cell: ({ row }) => (
302
+ <div className="flex justify-center">
303
+ <CBDot state={row.original.circuitBreaker} />
304
+ </div>
305
+ ),
306
+ },
307
+ {
308
+ accessorKey: 'lastSeen',
309
+ header: 'Health',
310
+ cell: ({ row }) => (
311
+ <div className="flex justify-center">
312
+ <HealthDot lastSeen={row.original.lastSeen} />
313
+ </div>
314
+ ),
315
+ },
316
+ ],
317
+ []
318
+ );
319
+
320
+ const table = useReactTable({
321
+ data: tableData,
322
+ columns,
323
+ state: { sorting },
324
+ onSortingChange: setSorting,
325
+ getCoreRowModel: getCoreRowModel(),
326
+ getSortedRowModel: getSortedRowModel(),
327
+ });
328
+
329
+ if (loading) return <TableSkeleton />;
330
+
331
+ if (tableData.length === 0) {
332
+ return (
333
+ <div className="bg-slate-900 rounded-sm border border-slate-800 p-8 text-center">
334
+ <p className="text-slate-500 font-mono text-sm">No project data available</p>
335
+ </div>
336
+ );
337
+ }
338
+
339
+ return (
340
+ <div className="bg-slate-900 rounded-sm border border-slate-800 overflow-hidden">
341
+ <table className="w-full">
342
+ <thead>
343
+ {table.getHeaderGroups().map((headerGroup) => (
344
+ <tr key={headerGroup.id} className="bg-slate-800/50 border-b border-slate-700">
345
+ {headerGroup.headers.map((header) => (
346
+ <th
347
+ key={header.id}
348
+ className={clsx(
349
+ 'px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400',
350
+ 'cursor-pointer hover:text-slate-200 transition-colors select-none'
351
+ )}
352
+ onClick={header.column.getToggleSortingHandler()}
353
+ >
354
+ <div className="flex items-center gap-1">
355
+ {flexRender(header.column.columnDef.header, header.getContext())}
356
+ <SortIndicator isSorted={header.column.getIsSorted()} />
357
+ </div>
358
+ </th>
359
+ ))}
360
+ </tr>
361
+ ))}
362
+ </thead>
363
+ <tbody>
364
+ {table.getRowModel().rows.map((row) => (
365
+ <tr
366
+ key={row.id}
367
+ className={clsx(
368
+ 'border-b border-slate-800 hover:bg-slate-800/30 transition-colors',
369
+ row.original.status === 'STOP' && 'bg-rose-500/5'
370
+ )}
371
+ >
372
+ {row.getVisibleCells().map((cell) => (
373
+ <td key={cell.id} className="px-4 py-3">
374
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
375
+ </td>
376
+ ))}
377
+ </tr>
378
+ ))}
379
+ </tbody>
380
+ </table>
381
+ </div>
382
+ );
383
+ }
384
+
385
+ export default UsageTable;
@@ -0,0 +1,305 @@
1
+ /**
2
+ * CircuitBreakerEvents Component (React)
3
+ *
4
+ * React version of the Circuit Breaker Event Log for the unified dashboard.
5
+ * Displays recent circuit breaker trip/reset events.
6
+ */
7
+
8
+ import { useState, useEffect, useCallback, useRef } from 'react';
9
+ import { RefreshCw, AlertTriangle, Zap, Check, Pause, Play } from 'lucide-react';
10
+ import { clsx } from 'clsx';
11
+ import { fetchWithDedup } from '../../../lib/usage/fetchWithDedup';
12
+
13
+ interface CircuitBreakerEvent {
14
+ id: string;
15
+ featureKey: string;
16
+ eventType: 'trip' | 'reset' | 'manual_disable' | 'manual_enable';
17
+ reason: string | null;
18
+ violatedResource: string | null;
19
+ currentValue: number | null;
20
+ budgetLimit: number | null;
21
+ autoReset: boolean;
22
+ alertSent: boolean;
23
+ createdAt: string;
24
+ }
25
+
26
+ type EventTypeFilter = '' | 'trip' | 'reset';
27
+
28
+ function getEventIcon(eventType: string) {
29
+ switch (eventType) {
30
+ case 'trip':
31
+ return <Zap className="w-3.5 h-3.5" />;
32
+ case 'reset':
33
+ return <Check className="w-3.5 h-3.5" />;
34
+ case 'manual_disable':
35
+ return <Pause className="w-3.5 h-3.5" />;
36
+ case 'manual_enable':
37
+ return <Play className="w-3.5 h-3.5" />;
38
+ default:
39
+ return <Zap className="w-3.5 h-3.5" />;
40
+ }
41
+ }
42
+
43
+ function getEventTypeLabel(eventType: string): string {
44
+ switch (eventType) {
45
+ case 'trip':
46
+ return 'Trip';
47
+ case 'reset':
48
+ return 'Reset';
49
+ case 'manual_disable':
50
+ return 'Manual Disable';
51
+ case 'manual_enable':
52
+ return 'Manual Enable';
53
+ default:
54
+ return eventType;
55
+ }
56
+ }
57
+
58
+ function formatTimeAgo(dateString: string): string {
59
+ const date = new Date(dateString + 'Z'); // Assume UTC
60
+ const now = new Date();
61
+ const diffMs = now.getTime() - date.getTime();
62
+ const diffMins = Math.floor(diffMs / 60000);
63
+ const diffHours = Math.floor(diffMins / 60);
64
+ const diffDays = Math.floor(diffHours / 24);
65
+
66
+ if (diffMins < 1) return 'Just now';
67
+ if (diffMins < 60) return `${diffMins}m ago`;
68
+ if (diffHours < 24) return `${diffHours}h ago`;
69
+ if (diffDays < 7) return `${diffDays}d ago`;
70
+ return date.toLocaleDateString();
71
+ }
72
+
73
+ const eventTypeColors: Record<string, { icon: string; badge: string }> = {
74
+ trip: {
75
+ icon: 'bg-rose-500/20 text-rose-400',
76
+ badge: 'bg-rose-500/20 text-rose-400',
77
+ },
78
+ reset: {
79
+ icon: 'bg-emerald-500/20 text-emerald-400',
80
+ badge: 'bg-emerald-500/20 text-emerald-400',
81
+ },
82
+ manual_disable: {
83
+ icon: 'bg-amber-500/20 text-amber-400',
84
+ badge: 'bg-amber-500/20 text-amber-400',
85
+ },
86
+ manual_enable: {
87
+ icon: 'bg-blue-500/20 text-blue-400',
88
+ badge: 'bg-blue-500/20 text-blue-400',
89
+ },
90
+ };
91
+
92
+ export function CircuitBreakerEvents() {
93
+ const [events, setEvents] = useState<CircuitBreakerEvent[]>([]);
94
+ const [loading, setLoading] = useState(true);
95
+ const [error, setError] = useState<string | null>(null);
96
+ const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
97
+ const [filter, setFilter] = useState<EventTypeFilter>('');
98
+
99
+ // Ref to track current filter for use in interval callback
100
+ const filterRef = useRef(filter);
101
+
102
+ // Keep ref in sync with state
103
+ useEffect(() => {
104
+ filterRef.current = filter;
105
+ }, [filter]);
106
+
107
+ const fetchEvents = useCallback(async () => {
108
+ setLoading(true);
109
+ setError(null);
110
+
111
+ try {
112
+ const params = new URLSearchParams({ limit: '50' });
113
+ // Use ref to get current filter value
114
+ if (filterRef.current) {
115
+ params.set('eventType', filterRef.current);
116
+ }
117
+
118
+ const data = await fetchWithDedup<{
119
+ success: boolean;
120
+ events?: CircuitBreakerEvent[];
121
+ error?: string;
122
+ }>(`/api/usage/features/circuit-breaker-events?${params}`);
123
+
124
+ if (data.success) {
125
+ setEvents(data.events || []);
126
+ setLastUpdated(new Date());
127
+ } else {
128
+ throw new Error(data.error || 'Failed to fetch events');
129
+ }
130
+ } catch (err) {
131
+ setError(err instanceof Error ? err.message : 'Unknown error');
132
+ } finally {
133
+ setLoading(false);
134
+ }
135
+ }, []); // No dependencies - uses ref for filter
136
+
137
+ // Fetch on mount and when filter changes
138
+ useEffect(() => {
139
+ fetchEvents();
140
+ }, [filter, fetchEvents]);
141
+
142
+ // Separate stable interval - doesn't restart on filter change
143
+ useEffect(() => {
144
+ const interval = setInterval(fetchEvents, 60_000);
145
+ return () => clearInterval(interval);
146
+ }, [fetchEvents]);
147
+
148
+ if (loading && events.length === 0) {
149
+ return (
150
+ <div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-sm p-6">
151
+ <div className="flex items-center justify-center gap-2 text-gray-600 dark:text-slate-400">
152
+ <RefreshCw className="w-4 h-4 animate-spin" />
153
+ <span className="text-sm">Loading circuit breaker events...</span>
154
+ </div>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ if (error) {
160
+ return (
161
+ <div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-sm p-6">
162
+ <div className="flex items-center gap-2 text-rose-400">
163
+ <AlertTriangle className="w-4 h-4" />
164
+ <span className="text-sm">{error}</span>
165
+ </div>
166
+ <button
167
+ type="button"
168
+ onClick={fetchEvents}
169
+ className="mt-3 px-3 py-1.5 bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 text-xs font-mono rounded-sm"
170
+ >
171
+ Retry
172
+ </button>
173
+ </div>
174
+ );
175
+ }
176
+
177
+ return (
178
+ <div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-sm">
179
+ {/* Header */}
180
+ <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-slate-800">
181
+ <div className="flex items-center gap-3">
182
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-slate-200">
183
+ Circuit Breaker Events
184
+ </h3>
185
+ <span className="text-xs text-gray-500 dark:text-slate-500 bg-gray-100 dark:bg-slate-800 px-2 py-0.5 rounded">
186
+ {events.length} event{events.length !== 1 ? 's' : ''}
187
+ </span>
188
+ </div>
189
+ <div className="flex items-center gap-2">
190
+ <select
191
+ value={filter}
192
+ onChange={(e) => setFilter(e.target.value as EventTypeFilter)}
193
+ className="bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-sm px-2 py-1 text-xs text-gray-700 dark:text-slate-300"
194
+ >
195
+ <option value="">All events</option>
196
+ <option value="trip">Trips only</option>
197
+ <option value="reset">Resets only</option>
198
+ </select>
199
+ <button
200
+ type="button"
201
+ onClick={fetchEvents}
202
+ disabled={loading}
203
+ className="p-1.5 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-sm transition-colors"
204
+ title="Refresh"
205
+ >
206
+ <RefreshCw
207
+ className={clsx(
208
+ 'w-4 h-4 text-gray-600 dark:text-slate-400',
209
+ loading && 'animate-spin'
210
+ )}
211
+ />
212
+ </button>
213
+ </div>
214
+ </div>
215
+
216
+ {/* Empty State */}
217
+ {events.length === 0 && (
218
+ <div className="p-8 text-center">
219
+ <div className="text-3xl mb-3">⚡</div>
220
+ <h4 className="text-sm font-semibold text-gray-900 dark:text-slate-200 mb-1">
221
+ No circuit breaker events
222
+ </h4>
223
+ <p className="text-xs text-gray-500 dark:text-slate-500 max-w-xs mx-auto">
224
+ Events will appear here when circuit breakers trip or reset due to budget violations.
225
+ </p>
226
+ </div>
227
+ )}
228
+
229
+ {/* Events List */}
230
+ {events.length > 0 && (
231
+ <div className="max-h-96 overflow-y-auto">
232
+ <div className="p-4 space-y-3">
233
+ {events.map((event) => {
234
+ const colors = eventTypeColors[event.eventType] || eventTypeColors.trip;
235
+
236
+ return (
237
+ <div
238
+ key={event.id}
239
+ className="flex items-start gap-3 p-3 bg-gray-100/30 dark:bg-slate-800/30 border border-gray-200/50 dark:border-slate-800/50 rounded-sm hover:border-gray-300 dark:hover:border-slate-700 transition-colors"
240
+ >
241
+ {/* Icon */}
242
+ <div
243
+ className={clsx(
244
+ 'flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center',
245
+ colors.icon
246
+ )}
247
+ >
248
+ {getEventIcon(event.eventType)}
249
+ </div>
250
+
251
+ {/* Content */}
252
+ <div className="flex-1 min-w-0">
253
+ {/* Header */}
254
+ <div className="flex items-baseline gap-2 mb-1">
255
+ <span className="text-sm font-medium text-gray-800 dark:text-slate-200 truncate">
256
+ {event.featureKey}
257
+ </span>
258
+ <span
259
+ className={clsx(
260
+ 'text-[10px] font-semibold uppercase px-1.5 py-0.5 rounded',
261
+ colors.badge
262
+ )}
263
+ >
264
+ {getEventTypeLabel(event.eventType)}
265
+ </span>
266
+ </div>
267
+
268
+ {/* Reason */}
269
+ {event.reason && (
270
+ <p className="text-xs text-gray-600 dark:text-slate-400 mb-1 truncate">
271
+ {event.reason}
272
+ </p>
273
+ )}
274
+
275
+ {/* Meta */}
276
+ <div className="flex items-center gap-3 text-[10px] text-gray-500 dark:text-slate-500">
277
+ <span>{formatTimeAgo(event.createdAt)}</span>
278
+ {event.autoReset && (
279
+ <span className="px-1.5 py-0.5 bg-indigo-500/20 text-indigo-400 rounded uppercase font-semibold">
280
+ Auto-reset
281
+ </span>
282
+ )}
283
+ {event.alertSent && (
284
+ <span className="px-1.5 py-0.5 bg-pink-500/20 text-pink-400 rounded uppercase font-semibold">
285
+ Alert sent
286
+ </span>
287
+ )}
288
+ </div>
289
+ </div>
290
+ </div>
291
+ );
292
+ })}
293
+ </div>
294
+ </div>
295
+ )}
296
+
297
+ {/* Footer */}
298
+ <div className="px-4 py-2 border-t border-gray-200 dark:border-slate-800 text-[10px] text-gray-500 dark:text-slate-500">
299
+ Last updated: {lastUpdated ? lastUpdated.toLocaleTimeString() : '--'}
300
+ </div>
301
+ </div>
302
+ );
303
+ }
304
+
305
+ export default CircuitBreakerEvents;