@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.
- package/README.md +2 -2
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +86 -2
- package/package.json +1 -1
- package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
- package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
- package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
- package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
- package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
- package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
- package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
- package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
- package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
- package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
- package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
- package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
- package/templates/full/dashboard/src/lib/search/api.ts +258 -0
- package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
- package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
- package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
- package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
- package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
- package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
- package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
- package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
- package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
- package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
- package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
- package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
- package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
- package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
- package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
- package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
- package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
- package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
- package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
- package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
- package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
- package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
- package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
- package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
- package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
- package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
- package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
- package/templates/shared/tests/helpers/mock-storage.ts +166 -0
- package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
- package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
- package/templates/shared/tests/unit/billing.test.ts +331 -0
- package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
- package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
- package/templates/shared/tests/unit/control.test.ts +226 -0
- package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
- package/templates/shared/tests/unit/economics.test.ts +365 -0
- package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
- package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
- package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
- package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
- package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
- package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
- package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
- package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
- package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
- package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
- package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
- package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
- package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
- package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
- package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
- package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
- package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
- package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
- package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
- 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
|
+
}
|