@littlebearapps/platform-admin-sdk 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +86 -2
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
  6. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  7. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  8. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  9. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  10. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  11. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  12. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  13. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  14. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  15. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  16. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  17. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  18. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  19. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  20. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  21. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  22. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  23. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  24. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  25. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  26. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  27. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  28. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  29. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  30. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  31. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  32. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  33. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  34. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  35. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  36. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  37. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  38. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  39. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  40. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  41. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  42. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  43. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  44. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  45. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  46. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  47. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  48. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  49. package/templates/shared/tests/unit/billing.test.ts +331 -0
  50. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  51. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  52. package/templates/shared/tests/unit/control.test.ts +226 -0
  53. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  54. package/templates/shared/tests/unit/economics.test.ts +365 -0
  55. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  56. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  57. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  58. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  59. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  60. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  61. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  62. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  63. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  64. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  65. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  66. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  67. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  68. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  69. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  70. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  71. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  72. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  73. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  74. package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
@@ -0,0 +1,472 @@
1
+ /**
2
+ * FeatureBudgets Component (React)
3
+ *
4
+ * React version of the Feature Budgets table for the unified dashboard.
5
+ * Displays feature-level usage metrics and circuit breaker status.
6
+ */
7
+
8
+ import { useState, useEffect, useCallback, useRef } from 'react';
9
+ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock } from 'lucide-react';
10
+ import { clsx } from 'clsx';
11
+ import { Sparkline } from './Sparkline';
12
+ import { fetchWithDedup } from '../../../lib/usage/fetchWithDedup';
13
+
14
+ interface FeatureMetrics {
15
+ d1Writes: number;
16
+ d1Reads: number;
17
+ kvReads: number;
18
+ kvWrites: number;
19
+ doRequests: number;
20
+ doGbSeconds: number;
21
+ r2ClassA: number;
22
+ r2ClassB: number;
23
+ aiNeurons: number;
24
+ queueMessages: number;
25
+ requests: number;
26
+ cpuMs: number;
27
+ }
28
+
29
+ interface FeatureCircuitBreaker {
30
+ enabled: boolean;
31
+ disabledReason?: string;
32
+ disabledAt?: string;
33
+ autoResetAt?: string;
34
+ }
35
+
36
+ interface FeatureData {
37
+ featureKey: string;
38
+ project: string;
39
+ category: string;
40
+ feature: string;
41
+ metrics: FeatureMetrics;
42
+ circuitBreaker: FeatureCircuitBreaker;
43
+ lastHeartbeat?: string;
44
+ healthStatus?: string;
45
+ }
46
+
47
+ interface HistoryDataPoint {
48
+ date: string;
49
+ requests: number;
50
+ d1Writes: number;
51
+ kvReads: number;
52
+ kvWrites: number;
53
+ }
54
+
55
+ type HistoryByFeature = Record<string, HistoryDataPoint[]>;
56
+
57
+ interface Budgets {
58
+ _defaults: Record<string, { hourly: number; daily?: number }>;
59
+ features: Record<string, Record<string, { hourly: number; daily?: number }>>;
60
+ }
61
+
62
+ function formatNumber(n: number): string {
63
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
64
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
65
+ return n.toFixed(0);
66
+ }
67
+
68
+ function getBudgetPct(value: number, limit: number): number {
69
+ if (limit <= 0) return 0;
70
+ return (value / limit) * 100;
71
+ }
72
+
73
+ function getPctClass(pct: number): string {
74
+ if (pct >= 90) return 'text-rose-400';
75
+ if (pct >= 70) return 'text-amber-400';
76
+ return 'text-gray-500 dark:text-slate-500';
77
+ }
78
+
79
+ /**
80
+ * Heartbeat Indicator - shows last seen timestamp with freshness status
81
+ */
82
+ function HeartbeatIndicator({ lastHeartbeat }: { lastHeartbeat?: string }) {
83
+ if (!lastHeartbeat) {
84
+ return (
85
+ <div className="flex items-center justify-center gap-1" title="No heartbeat received">
86
+ <Clock className="w-3 h-3 text-gray-400 dark:text-slate-600" />
87
+ <span className="text-[10px] font-mono text-gray-400 dark:text-slate-600">—</span>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ const lastSeenDate = new Date(lastHeartbeat);
93
+ const now = new Date();
94
+ const diffMs = now.getTime() - lastSeenDate.getTime();
95
+ const diffMins = Math.floor(diffMs / 60000);
96
+ const diffHours = Math.floor(diffMs / 3600000);
97
+
98
+ // Determine freshness: < 10min = fresh, < 1hr = recent, < 24hr = stale, > 24hr = dead
99
+ let status: 'fresh' | 'recent' | 'stale' | 'dead';
100
+ let label: string;
101
+
102
+ if (diffMins < 10) {
103
+ status = 'fresh';
104
+ label = diffMins <= 1 ? 'now' : `${diffMins}m`;
105
+ } else if (diffMins < 60) {
106
+ status = 'recent';
107
+ label = `${diffMins}m`;
108
+ } else if (diffHours < 24) {
109
+ status = 'stale';
110
+ label = `${diffHours}h`;
111
+ } else {
112
+ status = 'dead';
113
+ const diffDays = Math.floor(diffHours / 24);
114
+ label = `${diffDays}d`;
115
+ }
116
+
117
+ const statusColors = {
118
+ fresh: 'text-emerald-400',
119
+ recent: 'text-amber-400',
120
+ stale: 'text-orange-400',
121
+ dead: 'text-rose-400',
122
+ };
123
+
124
+ const dotColors = {
125
+ fresh: 'bg-emerald-500',
126
+ recent: 'bg-amber-500',
127
+ stale: 'bg-orange-500',
128
+ dead: 'bg-rose-500',
129
+ };
130
+
131
+ return (
132
+ <div
133
+ className="flex items-center justify-center gap-1"
134
+ title={`Last heartbeat: ${lastSeenDate.toLocaleString()}`}
135
+ >
136
+ <span className={clsx('w-1.5 h-1.5 rounded-full', dotColors[status])} />
137
+ <span className={clsx('text-[10px] font-mono', statusColors[status])}>{label}</span>
138
+ </div>
139
+ );
140
+ }
141
+
142
+ export function FeatureBudgets() {
143
+ const [features, setFeatures] = useState<FeatureData[]>([]);
144
+ const [history, setHistory] = useState<HistoryByFeature>({});
145
+ const [budgets, setBudgets] = useState<Budgets | null>(null);
146
+ const [loading, setLoading] = useState(true);
147
+ const [error, setError] = useState<string | null>(null);
148
+ const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
149
+
150
+ // Refs to track state for conditional fetching without creating dependency cycles
151
+ const budgetsRef = useRef(budgets);
152
+ const historyRef = useRef(history);
153
+
154
+ // Keep refs in sync with state
155
+ useEffect(() => {
156
+ budgetsRef.current = budgets;
157
+ }, [budgets]);
158
+
159
+ useEffect(() => {
160
+ historyRef.current = history;
161
+ }, [history]);
162
+
163
+ const fetchData = useCallback(async () => {
164
+ setLoading(true);
165
+ setError(null);
166
+
167
+ try {
168
+ // Use refs for conditional checks to avoid dependency cycle
169
+ const shouldFetchBudgets = !budgetsRef.current;
170
+ const shouldFetchHistory = Object.keys(historyRef.current).length === 0;
171
+
172
+ // Fetch features, budgets, and history in parallel with deduplication
173
+ const [featuresData, budgetsData, historyData] = await Promise.all([
174
+ fetchWithDedup<{ success: boolean; features?: FeatureData[]; error?: string }>(
175
+ '/api/usage/features'
176
+ ),
177
+ shouldFetchBudgets
178
+ ? fetchWithDedup<{ success: boolean; budgets?: Budgets }>(
179
+ '/api/usage/features/budgets',
180
+ {},
181
+ 30000 // Cache budgets for 30s (they don't change often)
182
+ )
183
+ : Promise.resolve(null),
184
+ shouldFetchHistory
185
+ ? fetchWithDedup<{ success: boolean; features?: HistoryByFeature }>(
186
+ '/api/usage/features/history?days=7',
187
+ {},
188
+ 30000 // Cache history for 30s
189
+ )
190
+ : Promise.resolve(null),
191
+ ]);
192
+
193
+ if (budgetsData?.success) {
194
+ setBudgets(budgetsData.budgets || null);
195
+ }
196
+
197
+ if (historyData?.success && historyData.features) {
198
+ setHistory(historyData.features);
199
+ }
200
+
201
+ if (featuresData.success) {
202
+ setFeatures(featuresData.features || []);
203
+ setLastUpdated(new Date());
204
+ } else {
205
+ throw new Error(featuresData.error || 'Failed to fetch features');
206
+ }
207
+ } catch (err) {
208
+ setError(err instanceof Error ? err.message : 'Unknown error');
209
+ } finally {
210
+ setLoading(false);
211
+ }
212
+ }, []); // No state dependencies - uses refs for conditional checks
213
+
214
+ useEffect(() => {
215
+ fetchData();
216
+ // Refresh every 60 seconds
217
+ const interval = setInterval(fetchData, 60_000);
218
+ return () => clearInterval(interval);
219
+ }, [fetchData]);
220
+
221
+ const handleToggle = async (featureKey: string, currentEnabled: boolean) => {
222
+ const action = currentEnabled ? 'disable' : 'enable';
223
+ const confirmed = window.confirm(
224
+ `${action.charAt(0).toUpperCase() + action.slice(1)} feature "${featureKey}"?`
225
+ );
226
+ if (!confirmed) return;
227
+
228
+ try {
229
+ const response = await fetch('/api/usage/features/circuit-breakers', {
230
+ method: 'PUT',
231
+ headers: { 'Content-Type': 'application/json' },
232
+ body: JSON.stringify({
233
+ featureKey,
234
+ enabled: !currentEnabled,
235
+ }),
236
+ credentials: 'include',
237
+ });
238
+
239
+ const result = await response.json();
240
+
241
+ if (result.success) {
242
+ await fetchData();
243
+ } else {
244
+ window.alert(`Failed to ${action}: ${result.error || 'Unknown error'}`);
245
+ }
246
+ } catch (_err) {
247
+ window.alert(`Failed to ${action} feature`);
248
+ }
249
+ };
250
+
251
+ const defaults = budgets?._defaults ?? {
252
+ d1Writes: { hourly: 10000 },
253
+ kvReads: { hourly: 50000 },
254
+ kvWrites: { hourly: 5000 },
255
+ };
256
+
257
+ if (loading && features.length === 0) {
258
+ return (
259
+ <div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-sm p-6">
260
+ <div className="flex items-center justify-center gap-2 text-gray-600 dark:text-slate-400">
261
+ <RefreshCw className="w-4 h-4 animate-spin" />
262
+ <span className="text-sm">Loading feature budgets...</span>
263
+ </div>
264
+ </div>
265
+ );
266
+ }
267
+
268
+ if (error) {
269
+ return (
270
+ <div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-sm p-6">
271
+ <div className="flex items-center gap-2 text-rose-400">
272
+ <AlertTriangle className="w-4 h-4" />
273
+ <span className="text-sm">{error}</span>
274
+ </div>
275
+ <button
276
+ type="button"
277
+ onClick={fetchData}
278
+ 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"
279
+ >
280
+ Retry
281
+ </button>
282
+ </div>
283
+ );
284
+ }
285
+
286
+ return (
287
+ <div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-800 rounded-sm">
288
+ {/* Header */}
289
+ <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-slate-800">
290
+ <div className="flex items-center gap-3">
291
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-slate-200">
292
+ Feature Budgets
293
+ </h3>
294
+ <span className="text-xs text-gray-500 dark:text-slate-500 bg-gray-100 dark:bg-slate-800 px-2 py-0.5 rounded">
295
+ {features.length} feature{features.length !== 1 ? 's' : ''}
296
+ </span>
297
+ </div>
298
+ <button
299
+ type="button"
300
+ onClick={fetchData}
301
+ disabled={loading}
302
+ className="p-1.5 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-sm transition-colors"
303
+ title="Refresh"
304
+ >
305
+ <RefreshCw
306
+ className={clsx('w-4 h-4 text-gray-600 dark:text-slate-400', loading && 'animate-spin')}
307
+ />
308
+ </button>
309
+ </div>
310
+
311
+ {/* Empty State */}
312
+ {features.length === 0 && (
313
+ <div className="p-8 text-center">
314
+ <div className="text-3xl mb-3">📊</div>
315
+ <h4 className="text-sm font-semibold text-gray-900 dark:text-slate-200 mb-1">
316
+ No feature telemetry yet
317
+ </h4>
318
+ <p className="text-xs text-gray-500 dark:text-slate-500 max-w-xs mx-auto">
319
+ Feature usage data will appear once projects start reporting telemetry using the{' '}
320
+ <code className="bg-gray-100 dark:bg-slate-800 px-1 py-0.5 rounded text-xs">
321
+ withFeatureBudget()
322
+ </code>{' '}
323
+ wrapper.
324
+ </p>
325
+ </div>
326
+ )}
327
+
328
+ {/* Table */}
329
+ {features.length > 0 && (
330
+ <div className="overflow-x-auto">
331
+ <table className="w-full text-xs">
332
+ <thead>
333
+ <tr className="border-b border-gray-200 dark:border-slate-800">
334
+ <th className="text-left px-4 py-2 text-gray-500 dark:text-slate-500 font-semibold uppercase tracking-wider">
335
+ Feature
336
+ </th>
337
+ <th className="text-left px-4 py-2 text-gray-500 dark:text-slate-500 font-semibold uppercase tracking-wider">
338
+ Project
339
+ </th>
340
+ <th className="text-right px-4 py-2 text-gray-500 dark:text-slate-500 font-semibold uppercase tracking-wider">
341
+ D1 Writes
342
+ </th>
343
+ <th className="text-right px-4 py-2 text-gray-500 dark:text-slate-500 font-semibold uppercase tracking-wider">
344
+ KV Ops
345
+ </th>
346
+ <th className="text-right px-4 py-2 text-gray-500 dark:text-slate-500 font-semibold uppercase tracking-wider">
347
+ Requests
348
+ </th>
349
+ <th className="text-center px-4 py-2 text-gray-500 dark:text-slate-500 font-semibold uppercase tracking-wider">
350
+ 7d Trend
351
+ </th>
352
+ <th className="text-center px-4 py-2 text-gray-500 dark:text-slate-500 font-semibold uppercase tracking-wider">
353
+ Heartbeat
354
+ </th>
355
+ <th className="text-center px-4 py-2 text-gray-500 dark:text-slate-500 font-semibold uppercase tracking-wider">
356
+ Status
357
+ </th>
358
+ <th className="text-center px-4 py-2 text-gray-500 dark:text-slate-500 font-semibold uppercase tracking-wider">
359
+ Action
360
+ </th>
361
+ </tr>
362
+ </thead>
363
+ <tbody>
364
+ {features.map((f) => {
365
+ const d1WritesPct = getBudgetPct(
366
+ f.metrics.d1Writes,
367
+ defaults.d1Writes?.hourly ?? 10000
368
+ );
369
+ const kvOps = f.metrics.kvReads + f.metrics.kvWrites;
370
+ const kvLimit =
371
+ (defaults.kvReads?.hourly ?? 50000) + (defaults.kvWrites?.hourly ?? 5000);
372
+ const kvOpsPct = getBudgetPct(kvOps, kvLimit);
373
+ const historyData = history[f.featureKey] || [];
374
+ const trendData = historyData.map(
375
+ (d) => d.requests || d.d1Writes + d.kvReads + d.kvWrites
376
+ );
377
+
378
+ return (
379
+ <tr
380
+ key={f.featureKey}
381
+ className="border-b border-gray-200/50 dark:border-slate-800/50 hover:bg-gray-100/30 dark:hover:bg-slate-800/30"
382
+ >
383
+ <td className="px-4 py-3">
384
+ <div>
385
+ <span className="text-gray-800 dark:text-slate-200 font-medium">
386
+ {f.feature}
387
+ </span>
388
+ <span className="block text-gray-500 dark:text-slate-500 text-[10px]">
389
+ {f.category}
390
+ </span>
391
+ </div>
392
+ </td>
393
+ <td className="px-4 py-3 text-gray-600 dark:text-slate-400">{f.project}</td>
394
+ <td className="px-4 py-3 text-right font-mono">
395
+ <span className="text-gray-800 dark:text-slate-200">
396
+ {formatNumber(f.metrics.d1Writes)}
397
+ </span>
398
+ <span className={clsx('block text-[10px]', getPctClass(d1WritesPct))}>
399
+ {d1WritesPct.toFixed(0)}%
400
+ </span>
401
+ </td>
402
+ <td className="px-4 py-3 text-right font-mono">
403
+ <span className="text-gray-800 dark:text-slate-200">
404
+ {formatNumber(kvOps)}
405
+ </span>
406
+ <span className={clsx('block text-[10px]', getPctClass(kvOpsPct))}>
407
+ {kvOpsPct.toFixed(0)}%
408
+ </span>
409
+ </td>
410
+ <td className="px-4 py-3 text-right font-mono text-gray-800 dark:text-slate-200">
411
+ {formatNumber(f.metrics.requests)}
412
+ </td>
413
+ <td className="px-4 py-3">
414
+ <div className="flex justify-center">
415
+ {trendData.length > 0 ? (
416
+ <Sparkline data={trendData} width={60} height={20} color="#3b82f6" />
417
+ ) : (
418
+ <span className="text-gray-400 dark:text-slate-600">—</span>
419
+ )}
420
+ </div>
421
+ </td>
422
+ <td className="px-4 py-3">
423
+ <HeartbeatIndicator lastHeartbeat={f.lastHeartbeat} />
424
+ </td>
425
+ <td className="px-4 py-3 text-center">
426
+ <span
427
+ className={clsx(
428
+ 'inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold uppercase',
429
+ f.circuitBreaker.enabled
430
+ ? 'bg-emerald-500/20 text-emerald-400'
431
+ : 'bg-rose-500/20 text-rose-400'
432
+ )}
433
+ >
434
+ {f.circuitBreaker.enabled ? (
435
+ <CheckCircle className="w-3 h-3" />
436
+ ) : (
437
+ <XCircle className="w-3 h-3" />
438
+ )}
439
+ {f.circuitBreaker.enabled ? 'Active' : 'Disabled'}
440
+ </span>
441
+ </td>
442
+ <td className="px-4 py-3 text-center">
443
+ <button
444
+ type="button"
445
+ onClick={() => handleToggle(f.featureKey, f.circuitBreaker.enabled)}
446
+ className={clsx(
447
+ 'px-2 py-1 text-[10px] font-semibold uppercase rounded transition-colors',
448
+ f.circuitBreaker.enabled
449
+ ? 'bg-rose-500/20 text-rose-400 hover:bg-rose-500/30'
450
+ : 'bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30'
451
+ )}
452
+ >
453
+ {f.circuitBreaker.enabled ? 'Disable' : 'Enable'}
454
+ </button>
455
+ </td>
456
+ </tr>
457
+ );
458
+ })}
459
+ </tbody>
460
+ </table>
461
+ </div>
462
+ )}
463
+
464
+ {/* Footer */}
465
+ <div className="px-4 py-2 border-t border-gray-200 dark:border-slate-800 text-[10px] text-gray-500 dark:text-slate-500">
466
+ Last updated: {lastUpdated ? lastUpdated.toLocaleTimeString() : '--'}
467
+ </div>
468
+ </div>
469
+ );
470
+ }
471
+
472
+ export default FeatureBudgets;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Error Collector API Client
3
+ */
4
+
5
+ import type { ErrorOccurrence, ErrorListResponse, ErrorStats, ErrorFilter } from './types';
6
+
7
+ const BASE_URL = '/api/errors';
8
+
9
+ /**
10
+ * List errors with optional filtering
11
+ */
12
+ export async function listErrors(filter: ErrorFilter = {}): Promise<ErrorListResponse> {
13
+ const params = new URLSearchParams();
14
+
15
+ if (filter.script) params.set('script', filter.script);
16
+ if (filter.priority) params.set('priority', filter.priority);
17
+ if (filter.status) params.set('status', filter.status);
18
+ if (filter.project) params.set('project', filter.project);
19
+ if (filter.error_type) params.set('error_type', filter.error_type);
20
+ if (filter.date_from) params.set('date_from', filter.date_from);
21
+ if (filter.date_to) params.set('date_to', filter.date_to);
22
+ if (filter.limit) params.set('limit', String(filter.limit));
23
+ if (filter.offset) params.set('offset', String(filter.offset));
24
+
25
+ const response = await fetch(`${BASE_URL}?${params.toString()}`);
26
+ if (!response.ok) {
27
+ throw new Error(`Failed to fetch errors: ${response.statusText}`);
28
+ }
29
+ return response.json();
30
+ }
31
+
32
+ /**
33
+ * Get error statistics for dashboard overview
34
+ */
35
+ export async function getErrorStats(): Promise<ErrorStats> {
36
+ const response = await fetch(`${BASE_URL}/stats`);
37
+ if (!response.ok) {
38
+ throw new Error(`Failed to fetch error stats: ${response.statusText}`);
39
+ }
40
+ return response.json();
41
+ }
42
+
43
+ /**
44
+ * Get single error by fingerprint
45
+ */
46
+ export async function getError(fingerprint: string): Promise<ErrorOccurrence> {
47
+ const response = await fetch(`${BASE_URL}/${fingerprint}`);
48
+ if (!response.ok) {
49
+ throw new Error(`Failed to fetch error: ${response.statusText}`);
50
+ }
51
+ return response.json();
52
+ }
53
+
54
+ /**
55
+ * Mute an error (add cf:muted label to GitHub issue)
56
+ */
57
+ export async function muteError(
58
+ fingerprint: string
59
+ ): Promise<{ success: boolean; message: string }> {
60
+ const response = await fetch(`${BASE_URL}/${fingerprint}/mute`, {
61
+ method: 'POST',
62
+ });
63
+ if (!response.ok) {
64
+ const data = await response.json();
65
+ throw new Error(data.error || 'Failed to mute error');
66
+ }
67
+ return response.json();
68
+ }
69
+
70
+ /**
71
+ * Resolve an error manually
72
+ */
73
+ export async function resolveError(
74
+ fingerprint: string
75
+ ): Promise<{ success: boolean; message: string }> {
76
+ const response = await fetch(`${BASE_URL}/${fingerprint}/resolve`, {
77
+ method: 'POST',
78
+ });
79
+ if (!response.ok) {
80
+ const data = await response.json();
81
+ throw new Error(data.error || 'Failed to resolve error');
82
+ }
83
+ return response.json();
84
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Error Collector Dashboard Types
3
+ */
4
+
5
+ export type ErrorType = 'exception' | 'cpu_limit' | 'memory_limit' | 'soft_error' | 'warning';
6
+ export type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
7
+ export type ErrorStatus = 'open' | 'resolved' | 'wont_fix' | 'pending_digest' | 'digested';
8
+
9
+ export interface ErrorOccurrence {
10
+ id: string;
11
+ fingerprint: string;
12
+ script_name: string;
13
+ project: string;
14
+ error_type: ErrorType;
15
+ priority: Priority;
16
+ github_issue_number?: number;
17
+ github_issue_url?: string;
18
+ github_repo: string;
19
+ status: ErrorStatus;
20
+ resolved_at?: number;
21
+ resolved_by?: string;
22
+ first_seen_at: number;
23
+ last_seen_at: number;
24
+ occurrence_count: number;
25
+ last_request_url?: string;
26
+ last_request_method?: string;
27
+ last_colo?: string;
28
+ last_country?: string;
29
+ last_cf_ray?: string;
30
+ last_exception_name?: string;
31
+ last_exception_message?: string;
32
+ last_logs_json?: string;
33
+ normalized_message?: string;
34
+ error_category?: string;
35
+ }
36
+
37
+ export interface ErrorListResponse {
38
+ errors: ErrorOccurrence[];
39
+ total: number;
40
+ limit: number;
41
+ offset: number;
42
+ }
43
+
44
+ export interface ErrorStats {
45
+ byPriority: Record<string, number>;
46
+ byStatus: Record<string, number>;
47
+ byType: Record<string, number>;
48
+ byProject: Record<string, number>;
49
+ byTransientCategory: Record<string, number>;
50
+ recentCount: number;
51
+ todayOccurrences: number;
52
+ totalOpen: number;
53
+ }
54
+
55
+ export type ErrorSortColumn =
56
+ | 'priority'
57
+ | 'script_name'
58
+ | 'status'
59
+ | 'occurrence_count'
60
+ | 'last_seen_at';
61
+ export type SortOrder = 'asc' | 'desc';
62
+
63
+ export interface ErrorFilter {
64
+ script?: string;
65
+ priority?: Priority;
66
+ status?: ErrorStatus;
67
+ project?: string;
68
+ error_type?: ErrorType;
69
+ date_from?: string;
70
+ date_to?: string;
71
+ limit?: number;
72
+ offset?: number;
73
+ sort_by?: ErrorSortColumn;
74
+ sort_order?: SortOrder;
75
+ }