@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,474 @@
1
+ /**
2
+ * Circuit Breaker Report Component
3
+ * Shows global, project, and feature-level circuit breaker states with trip/reset controls
4
+ */
5
+ import { useState, useEffect, useCallback } from 'react';
6
+
7
+ interface BreakerState {
8
+ level: 'global' | 'project' | 'feature';
9
+ key: string;
10
+ label: string;
11
+ status: 'active' | 'stopped';
12
+ reason?: string;
13
+ }
14
+
15
+ function StatusBadge({ status }: { status: string }) {
16
+ const isActive = status === 'active';
17
+ return (
18
+ <span className={`px-2 py-0.5 text-xs font-semibold rounded ${
19
+ isActive
20
+ ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
21
+ : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
22
+ }`}>
23
+ {isActive ? 'ACTIVE' : 'STOPPED'}
24
+ </span>
25
+ );
26
+ }
27
+
28
+ function LevelBadge({ level }: { level: string }) {
29
+ const colours: Record<string, string> = {
30
+ global: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
31
+ project: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
32
+ feature: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300',
33
+ };
34
+ return (
35
+ <span className={`px-2 py-0.5 text-xs font-semibold rounded ${colours[level] || colours.feature}`}>
36
+ {level.toUpperCase()}
37
+ </span>
38
+ );
39
+ }
40
+
41
+ // Inline confirmation with reason input
42
+ function ActionConfirm({
43
+ breaker,
44
+ action,
45
+ onConfirm,
46
+ onCancel,
47
+ }: {
48
+ breaker: BreakerState;
49
+ action: 'trip' | 'reset';
50
+ onConfirm: (reason: string) => void;
51
+ onCancel: () => void;
52
+ }) {
53
+ const [reason, setReason] = useState('');
54
+ const isTrip = action === 'trip';
55
+ const isGlobal = breaker.level === 'global';
56
+
57
+ return (
58
+ <div className={`mt-2 p-3 rounded-lg border ${
59
+ isTrip
60
+ ? 'bg-red-50 dark:bg-red-900/10 border-red-200 dark:border-red-800'
61
+ : 'bg-green-50 dark:bg-green-900/10 border-green-200 dark:border-green-800'
62
+ }`}>
63
+ <p className={`text-sm font-medium ${isTrip ? 'text-red-700 dark:text-red-300' : 'text-green-700 dark:text-green-300'}`}>
64
+ {isTrip && isGlobal && 'WARNING: This will stop ALL features across ALL projects.'}
65
+ {isTrip && breaker.level === 'project' && `This will stop all features for ${breaker.label}.`}
66
+ {isTrip && breaker.level === 'feature' && `This will stop the feature: ${breaker.label}.`}
67
+ {!isTrip && `Reset ${breaker.label} to active?`}
68
+ </p>
69
+ <div className="mt-2 flex items-center gap-2">
70
+ <input
71
+ type="text"
72
+ placeholder="Reason (optional)"
73
+ value={reason}
74
+ onChange={(e) => setReason(e.target.value)}
75
+ className="flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
76
+ />
77
+ <button
78
+ onClick={() => onConfirm(reason)}
79
+ className={`px-3 py-1.5 text-sm font-medium rounded-lg text-white ${
80
+ isTrip
81
+ ? 'bg-red-600 hover:bg-red-700'
82
+ : 'bg-green-600 hover:bg-green-700'
83
+ }`}
84
+ >
85
+ {isTrip ? 'Confirm Trip' : 'Confirm Reset'}
86
+ </button>
87
+ <button
88
+ onClick={onCancel}
89
+ className="px-3 py-1.5 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
90
+ >
91
+ Cancel
92
+ </button>
93
+ </div>
94
+ </div>
95
+ );
96
+ }
97
+
98
+ export function CircuitBreakerReport() {
99
+ const [breakers, setBreakers] = useState<BreakerState[]>([]);
100
+ const [recentlyTripped, setRecentlyTripped] = useState<BreakerState[]>([]);
101
+ const [loading, setLoading] = useState(true);
102
+ const [error, setError] = useState<string | null>(null);
103
+ const [actionTarget, setActionTarget] = useState<{ breaker: BreakerState; action: 'trip' | 'reset' } | null>(null);
104
+ const [actionMessage, setActionMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
105
+ const [actionLoading, setActionLoading] = useState(false);
106
+
107
+ // Manual feature trip state
108
+ const [manualFeatureKey, setManualFeatureKey] = useState('');
109
+ const [manualTripping, setManualTripping] = useState(false);
110
+ const [manualReason, setManualReason] = useState('');
111
+
112
+ const fetchData = useCallback(async () => {
113
+ try {
114
+ setLoading(true);
115
+ const res = await fetch('/api/usage/circuit-breakers');
116
+ if (!res.ok) throw new Error('Failed to fetch circuit breaker data');
117
+ const data = await res.json();
118
+ setBreakers(data.breakers || []);
119
+ setRecentlyTripped(data.recentlyTripped || []);
120
+ } catch (e) {
121
+ setError(e instanceof Error ? e.message : 'Unknown error');
122
+ } finally {
123
+ setLoading(false);
124
+ }
125
+ }, []);
126
+
127
+ useEffect(() => { fetchData(); }, [fetchData]);
128
+
129
+ async function handleAction(level: string, key: string, action: 'trip' | 'reset', reason: string) {
130
+ setActionLoading(true);
131
+ setActionMessage(null);
132
+ try {
133
+ const res = await fetch('/api/usage/circuit-breakers', {
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/json' },
136
+ body: JSON.stringify({ level, key, action, reason: reason || undefined }),
137
+ });
138
+ const data = await res.json();
139
+ if (data.success) {
140
+ setActionMessage({ type: 'success', text: `Successfully ${action === 'trip' ? 'tripped' : 'reset'}: ${key}` });
141
+ setActionTarget(null);
142
+ await fetchData();
143
+ } else {
144
+ setActionMessage({ type: 'error', text: data.error || `Failed to ${action} breaker` });
145
+ }
146
+ } catch (e) {
147
+ setActionMessage({ type: 'error', text: e instanceof Error ? e.message : 'Unknown error' });
148
+ } finally {
149
+ setActionLoading(false);
150
+ }
151
+ }
152
+
153
+ async function handleManualFeatureTrip() {
154
+ if (!manualFeatureKey.trim()) return;
155
+ setManualTripping(true);
156
+ setActionMessage(null);
157
+ try {
158
+ const res = await fetch('/api/usage/circuit-breakers', {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({ level: 'feature', key: manualFeatureKey.trim(), action: 'trip', reason: manualReason || undefined }),
162
+ });
163
+ const data = await res.json();
164
+ if (data.success) {
165
+ setActionMessage({ type: 'success', text: `Tripped feature: ${manualFeatureKey.trim()}` });
166
+ setManualFeatureKey('');
167
+ setManualReason('');
168
+ await fetchData();
169
+ } else {
170
+ setActionMessage({ type: 'error', text: data.error || 'Failed to trip feature' });
171
+ }
172
+ } catch (e) {
173
+ setActionMessage({ type: 'error', text: e instanceof Error ? e.message : 'Unknown error' });
174
+ } finally {
175
+ setManualTripping(false);
176
+ }
177
+ }
178
+
179
+ if (loading) {
180
+ return (
181
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
182
+ {[...Array(4)].map((_, i) => (
183
+ <div key={i} className="bg-white dark:bg-gray-800 rounded-lg border p-4 animate-pulse">
184
+ <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2"></div>
185
+ <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-12"></div>
186
+ </div>
187
+ ))}
188
+ </div>
189
+ );
190
+ }
191
+
192
+ if (error) {
193
+ return (
194
+ <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-300">
195
+ Error loading circuit breaker data: {error}
196
+ </div>
197
+ );
198
+ }
199
+
200
+ const globalBreaker = breakers.find(b => b.level === 'global');
201
+ const projectBreakers = breakers.filter(b => b.level === 'project');
202
+ const stoppedCount = breakers.filter(b => b.status === 'stopped').length;
203
+ const activeCount = breakers.filter(b => b.status === 'active').length;
204
+
205
+ return (
206
+ <div className="space-y-6">
207
+ {/* Action feedback */}
208
+ {actionMessage && (
209
+ <div className={`p-3 rounded-lg border text-sm font-medium ${
210
+ actionMessage.type === 'success'
211
+ ? 'bg-green-50 dark:bg-green-900/10 border-green-200 dark:border-green-800 text-green-700 dark:text-green-300'
212
+ : 'bg-red-50 dark:bg-red-900/10 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300'
213
+ }`}>
214
+ {actionMessage.text}
215
+ <button
216
+ onClick={() => setActionMessage(null)}
217
+ className="ml-2 text-xs underline opacity-70 hover:opacity-100"
218
+ >
219
+ dismiss
220
+ </button>
221
+ </div>
222
+ )}
223
+
224
+ {/* Stats */}
225
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
226
+ <div className={`bg-white dark:bg-gray-800 rounded-lg border p-4 border-l-4 ${
227
+ globalBreaker?.status === 'stopped' ? 'border-l-red-500' : 'border-l-green-500'
228
+ }`}>
229
+ <p className="text-sm font-medium text-gray-600 dark:text-gray-400">Global Status</p>
230
+ <p className={`text-2xl font-bold mt-1 ${
231
+ globalBreaker?.status === 'stopped' ? 'text-red-600' : 'text-green-600'
232
+ }`}>
233
+ {globalBreaker?.status === 'stopped' ? 'STOPPED' : 'Active'}
234
+ </p>
235
+ </div>
236
+ <div className={`bg-white dark:bg-gray-800 rounded-lg border p-4 border-l-4 ${
237
+ stoppedCount > 0 ? 'border-l-red-500' : 'border-l-green-500'
238
+ }`}>
239
+ <p className="text-sm font-medium text-gray-600 dark:text-gray-400">Stopped</p>
240
+ <p className={`text-2xl font-bold mt-1 ${stoppedCount > 0 ? 'text-red-600' : 'text-green-600'}`}>
241
+ {stoppedCount}
242
+ </p>
243
+ </div>
244
+ <div className="bg-white dark:bg-gray-800 rounded-lg border p-4 border-l-4 border-l-green-500">
245
+ <p className="text-sm font-medium text-gray-600 dark:text-gray-400">Active</p>
246
+ <p className="text-2xl font-bold mt-1 text-green-600">{activeCount}</p>
247
+ </div>
248
+ <div className="bg-white dark:bg-gray-800 rounded-lg border p-4 border-l-4 border-l-orange-500">
249
+ <p className="text-sm font-medium text-gray-600 dark:text-gray-400">Recently Tripped</p>
250
+ <p className="text-2xl font-bold mt-1">{recentlyTripped.length}</p>
251
+ </div>
252
+ </div>
253
+
254
+ {/* Breaker States Table */}
255
+ <div className="bg-white dark:bg-gray-800 rounded-lg border overflow-hidden">
256
+ <div className="px-4 py-3 border-b">
257
+ <h3 className="font-semibold text-gray-900 dark:text-white">Circuit Breaker States</h3>
258
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
259
+ Global kill switch and per-project circuit breakers — click Trip/Reset to control
260
+ </p>
261
+ </div>
262
+ <div className="overflow-x-auto">
263
+ <table className="w-full">
264
+ <thead className="bg-gray-50 dark:bg-gray-900/50">
265
+ <tr>
266
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Level</th>
267
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Label</th>
268
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
269
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Reason</th>
270
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Actions</th>
271
+ </tr>
272
+ </thead>
273
+ <tbody>
274
+ {/* Global first */}
275
+ {globalBreaker && (
276
+ <tr className="border-b hover:bg-gray-50 dark:hover:bg-gray-800/50">
277
+ <td className="px-4 py-3"><LevelBadge level="global" /></td>
278
+ <td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{globalBreaker.label}</td>
279
+ <td className="px-4 py-3"><StatusBadge status={globalBreaker.status} /></td>
280
+ <td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{globalBreaker.reason || '-'}</td>
281
+ <td className="px-4 py-3 text-right">
282
+ {globalBreaker.status === 'active' ? (
283
+ <button
284
+ onClick={() => setActionTarget({ breaker: globalBreaker, action: 'trip' })}
285
+ disabled={actionLoading}
286
+ className="px-3 py-1 text-xs font-medium rounded bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50 disabled:opacity-40"
287
+ >
288
+ Trip
289
+ </button>
290
+ ) : (
291
+ <button
292
+ onClick={() => setActionTarget({ breaker: globalBreaker, action: 'reset' })}
293
+ disabled={actionLoading}
294
+ className="px-3 py-1 text-xs font-medium rounded bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-300 dark:hover:bg-green-900/50 disabled:opacity-40"
295
+ >
296
+ Reset
297
+ </button>
298
+ )}
299
+ </td>
300
+ </tr>
301
+ )}
302
+ {/* Show inline confirm for global */}
303
+ {actionTarget?.breaker.level === 'global' && (
304
+ <tr>
305
+ <td colSpan={5} className="px-4 pb-3">
306
+ <ActionConfirm
307
+ breaker={actionTarget.breaker}
308
+ action={actionTarget.action}
309
+ onConfirm={(reason) => handleAction('global', actionTarget.breaker.key, actionTarget.action, reason)}
310
+ onCancel={() => setActionTarget(null)}
311
+ />
312
+ </td>
313
+ </tr>
314
+ )}
315
+ {/* Per-project */}
316
+ {projectBreakers.map(b => (
317
+ <>
318
+ <tr key={b.key} className="border-b hover:bg-gray-50 dark:hover:bg-gray-800/50">
319
+ <td className="px-4 py-3"><LevelBadge level="project" /></td>
320
+ <td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{b.label}</td>
321
+ <td className="px-4 py-3"><StatusBadge status={b.status} /></td>
322
+ <td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{b.reason || '-'}</td>
323
+ <td className="px-4 py-3 text-right">
324
+ {b.status === 'active' ? (
325
+ <button
326
+ onClick={() => setActionTarget({ breaker: b, action: 'trip' })}
327
+ disabled={actionLoading}
328
+ className="px-3 py-1 text-xs font-medium rounded bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50 disabled:opacity-40"
329
+ >
330
+ Trip
331
+ </button>
332
+ ) : (
333
+ <button
334
+ onClick={() => setActionTarget({ breaker: b, action: 'reset' })}
335
+ disabled={actionLoading}
336
+ className="px-3 py-1 text-xs font-medium rounded bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-300 dark:hover:bg-green-900/50 disabled:opacity-40"
337
+ >
338
+ Reset
339
+ </button>
340
+ )}
341
+ </td>
342
+ </tr>
343
+ {/* Show inline confirm for this project */}
344
+ {actionTarget?.breaker.key === b.key && actionTarget.breaker.level === 'project' && (
345
+ <tr key={`${b.key}-confirm`}>
346
+ <td colSpan={5} className="px-4 pb-3">
347
+ <ActionConfirm
348
+ breaker={actionTarget.breaker}
349
+ action={actionTarget.action}
350
+ onConfirm={(reason) => handleAction('project', b.key, actionTarget.action, reason)}
351
+ onCancel={() => setActionTarget(null)}
352
+ />
353
+ </td>
354
+ </tr>
355
+ )}
356
+ </>
357
+ ))}
358
+ </tbody>
359
+ </table>
360
+ </div>
361
+ </div>
362
+
363
+ {/* Recently Tripped Features */}
364
+ {recentlyTripped.length > 0 ? (
365
+ <div className="bg-white dark:bg-gray-800 rounded-lg border overflow-hidden">
366
+ <div className="px-4 py-3 border-b">
367
+ <h3 className="font-semibold text-gray-900 dark:text-white">Recently Tripped Features (24h)</h3>
368
+ </div>
369
+ <div className="overflow-x-auto">
370
+ <table className="w-full">
371
+ <thead className="bg-gray-50 dark:bg-gray-900/50">
372
+ <tr>
373
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Feature</th>
374
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Current Status</th>
375
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Reason</th>
376
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Actions</th>
377
+ </tr>
378
+ </thead>
379
+ <tbody>
380
+ {recentlyTripped.map(b => (
381
+ <>
382
+ <tr key={b.key} className="border-b hover:bg-gray-50 dark:hover:bg-gray-800/50">
383
+ <td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{b.label}</td>
384
+ <td className="px-4 py-3"><StatusBadge status={b.status} /></td>
385
+ <td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate">
386
+ {b.reason || '-'}
387
+ </td>
388
+ <td className="px-4 py-3 text-right">
389
+ {b.status === 'stopped' ? (
390
+ <button
391
+ onClick={() => setActionTarget({ breaker: b, action: 'reset' })}
392
+ disabled={actionLoading}
393
+ className="px-3 py-1 text-xs font-medium rounded bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-300 dark:hover:bg-green-900/50 disabled:opacity-40"
394
+ >
395
+ Reset
396
+ </button>
397
+ ) : (
398
+ <button
399
+ onClick={() => setActionTarget({ breaker: b, action: 'trip' })}
400
+ disabled={actionLoading}
401
+ className="px-3 py-1 text-xs font-medium rounded bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50 disabled:opacity-40"
402
+ >
403
+ Trip
404
+ </button>
405
+ )}
406
+ </td>
407
+ </tr>
408
+ {actionTarget?.breaker.key === b.key && actionTarget.breaker.level === 'feature' && (
409
+ <tr key={`${b.key}-confirm`}>
410
+ <td colSpan={4} className="px-4 pb-3">
411
+ <ActionConfirm
412
+ breaker={actionTarget.breaker}
413
+ action={actionTarget.action}
414
+ onConfirm={(reason) => handleAction('feature', b.key, actionTarget.action, reason)}
415
+ onCancel={() => setActionTarget(null)}
416
+ />
417
+ </td>
418
+ </tr>
419
+ )}
420
+ </>
421
+ ))}
422
+ </tbody>
423
+ </table>
424
+ </div>
425
+ </div>
426
+ ) : (
427
+ <div className="bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800 rounded-lg p-4 text-green-700 dark:text-green-300 text-sm">
428
+ No feature circuit breakers have tripped in the last 24 hours.
429
+ </div>
430
+ )}
431
+
432
+ {/* Manual Feature Trip */}
433
+ <div className="bg-white dark:bg-gray-800 rounded-lg border overflow-hidden">
434
+ <div className="px-4 py-3 border-b">
435
+ <h3 className="font-semibold text-gray-900 dark:text-white">Manual Feature Control</h3>
436
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
437
+ Trip or manage a specific feature by key (format: project:category:feature)
438
+ </p>
439
+ </div>
440
+ <div className="p-4">
441
+ <div className="flex items-end gap-3">
442
+ <div className="flex-1">
443
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Feature Key</label>
444
+ <input
445
+ type="text"
446
+ placeholder="e.g. my-project:scanner:github"
447
+ value={manualFeatureKey}
448
+ onChange={(e) => setManualFeatureKey(e.target.value)}
449
+ className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
450
+ />
451
+ </div>
452
+ <div className="w-48">
453
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reason (optional)</label>
454
+ <input
455
+ type="text"
456
+ placeholder="Manual override"
457
+ value={manualReason}
458
+ onChange={(e) => setManualReason(e.target.value)}
459
+ className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
460
+ />
461
+ </div>
462
+ <button
463
+ onClick={handleManualFeatureTrip}
464
+ disabled={!manualFeatureKey.trim() || manualTripping}
465
+ className="px-4 py-2 text-sm font-medium rounded-lg bg-red-600 text-white hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
466
+ >
467
+ {manualTripping ? 'Tripping...' : 'Trip Feature'}
468
+ </button>
469
+ </div>
470
+ </div>
471
+ </div>
472
+ </div>
473
+ );
474
+ }