@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,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
|
+
}
|