@littlebearapps/platform-admin-sdk 1.5.0 → 2.0.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/dist/templates.d.ts +1 -1
- package/dist/templates.js +112 -1
- package/package.json +1 -1
- package/templates/full/dashboard/src/components/patterns/ActivePatterns.tsx +62 -0
- package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
- package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
- package/templates/full/dashboard/src/components/patterns/index.ts +3 -0
- package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
- package/templates/full/dashboard/src/components/reports/SdkAuditReport.tsx +72 -0
- package/templates/full/dashboard/src/components/reports/index.ts +2 -0
- package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
- package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -0
- package/templates/full/dashboard/src/pages/api/patterns/cache-refresh.ts +38 -0
- package/templates/full/dashboard/src/pages/api/patterns/discover.ts +36 -0
- package/templates/full/dashboard/src/pages/api/patterns/ready-for-review.ts +39 -0
- package/templates/full/dashboard/src/pages/api/patterns/stats.ts +39 -0
- package/templates/full/dashboard/src/pages/api/patterns/suggestions.ts +43 -0
- package/templates/full/dashboard/src/pages/api/reports/audit.ts +45 -0
- package/templates/full/dashboard/src/pages/api/reports/usage.ts +52 -0
- package/templates/full/dashboard/src/pages/api/search/reindex.ts +28 -0
- package/templates/full/dashboard/src/pages/api/search/stats.ts +27 -0
- package/templates/full/dashboard/src/pages/api/settings/index.ts +37 -0
- package/templates/full/dashboard/src/pages/api/settings/update.ts +41 -0
- package/templates/full/dashboard/src/pages/api/topology/index.ts +56 -0
- package/templates/full/scripts/ops/universal-backfill.ts +147 -0
- package/templates/shared/.github/workflows/contract-check.yml.hbs +42 -0
- package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
- package/templates/shared/.github/workflows/security.yml +33 -0
- package/templates/shared/dashboard/src/components/Nav.astro.hbs +2 -0
- package/templates/shared/dashboard/src/components/infrastructure/AlertHistory.tsx +57 -0
- package/templates/shared/dashboard/src/components/infrastructure/InfrastructureStats.tsx +73 -0
- package/templates/shared/dashboard/src/components/infrastructure/ServiceRegistry.tsx +55 -0
- package/templates/shared/dashboard/src/components/infrastructure/UptimeStatus.tsx +56 -0
- package/templates/shared/dashboard/src/components/infrastructure/index.ts +4 -0
- package/templates/shared/dashboard/src/components/ui/Breadcrumbs.tsx +27 -0
- package/templates/shared/dashboard/src/components/ui/EmptyState.tsx +26 -0
- package/templates/shared/dashboard/src/components/ui/ErrorBoundary.tsx +42 -0
- package/templates/shared/dashboard/src/components/ui/LoadingSkeleton.tsx +18 -0
- package/templates/shared/dashboard/src/components/ui/PageShell.tsx +26 -0
- package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
- package/templates/shared/dashboard/src/components/ui/index.ts +6 -0
- package/templates/shared/dashboard/src/components/usage/AnomaliesWidget.tsx +68 -0
- package/templates/shared/dashboard/src/components/usage/HourlyUsageChart.tsx +55 -0
- package/templates/shared/dashboard/src/components/usage/PlanAllowanceDashboard.tsx +67 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCostBreakdown.tsx +55 -0
- package/templates/shared/dashboard/src/components/usage/index.ts +4 -0
- package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -0
- package/templates/shared/dashboard/src/pages/api/costs/overview.ts +65 -0
- package/templates/shared/dashboard/src/pages/api/costs/providers.ts +47 -0
- package/templates/shared/dashboard/src/pages/api/infrastructure/services.ts +55 -0
- package/templates/shared/dashboard/src/pages/api/infrastructure/stats.ts +99 -0
- package/templates/shared/dashboard/src/pages/api/usage/allowances.ts +56 -0
- package/templates/shared/dashboard/src/pages/api/usage/anomalies.ts +45 -0
- package/templates/shared/dashboard/src/pages/api/usage/billing.ts +53 -0
- package/templates/shared/dashboard/src/pages/api/usage/granular.ts +50 -0
- package/templates/shared/dashboard/src/pages/api/usage/hourly.ts +45 -0
- package/templates/shared/dashboard/src/pages/api/usage/projects.ts +51 -0
- package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -0
- package/templates/shared/dashboard/src/pages/settings/notifications.astro +34 -0
- package/templates/shared/dashboard/src/pages/settings/thresholds.astro +39 -0
- package/templates/shared/dashboard/src/pages/settings/usage.astro +28 -0
- package/templates/shared/docs/architecture.md +89 -0
- package/templates/shared/docs/post-deploy-runbook.md +126 -0
- package/templates/shared/docs/troubleshooting.md +91 -0
- package/templates/shared/package.json.hbs +5 -0
- package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
- package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -0
- package/templates/shared/scripts/ops/validate-controls.js +141 -0
- package/templates/shared/tests/contract/validate-schemas.test.ts +130 -0
- package/templates/shared/tests/fixtures/telemetry-envelope-invalid.json +9 -0
- package/templates/shared/tests/fixtures/telemetry-envelope-valid.json +27 -0
- package/templates/shared/tests/helpers/mock-d1.ts +61 -0
- package/templates/shared/tests/helpers/mock-kv.ts +37 -0
- package/templates/shared/tests/unit/workers/batch-persistence.test.ts +133 -0
- package/templates/shared/tests/unit/workers/budget-enforcement.test.ts +214 -0
- package/templates/shared/vitest.config.ts +18 -0
- package/templates/standard/dashboard/src/components/health/CircuitBreakerEvents.tsx +69 -0
- package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -0
- package/templates/standard/dashboard/src/components/health/index.ts +2 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/mute.ts +49 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/resolve.ts +36 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint].ts +55 -0
- package/templates/standard/dashboard/src/pages/api/health/audit-history.ts +37 -0
- package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -0
- package/templates/standard/tests/unit/error-collector/capture.test.ts +106 -0
- package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit Tests for Budget Enforcement
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Feature-level budget warnings at 70%/90% thresholds
|
|
6
|
+
* - Circuit breaker trip at 100%
|
|
7
|
+
* - Monthly budget tracking
|
|
8
|
+
* - Dedup via KV BUDGET_WARN: prefix
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import { createMockKV } from '../../helpers/mock-kv';
|
|
13
|
+
import { MockD1Database } from '../../helpers/mock-d1';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Module mocks — must be before imports that resolve these modules
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
vi.mock('@littlebearapps/platform-consumer-sdk', async (importOriginal) => {
|
|
20
|
+
const actual = await importOriginal();
|
|
21
|
+
return {
|
|
22
|
+
...actual,
|
|
23
|
+
createLoggerFromEnv: () => ({
|
|
24
|
+
info: vi.fn(),
|
|
25
|
+
warn: vi.fn(),
|
|
26
|
+
error: vi.fn(),
|
|
27
|
+
debug: vi.fn(),
|
|
28
|
+
}),
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
vi.mock('../../../workers/lib/circuit-breaker-middleware', () => ({
|
|
33
|
+
CB_STATUS: { CLOSED: 'active', WARNING: 'warning', OPEN: 'paused' },
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock('../../../workers/lib/platform-settings', () => ({
|
|
37
|
+
getPlatformSettings: vi.fn().mockResolvedValue({}),
|
|
38
|
+
getProjectSetting: vi.fn().mockResolvedValue(null),
|
|
39
|
+
DEFAULT_PLATFORM_SETTINGS: {},
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const mockFetch = vi.fn().mockResolvedValue(new Response('ok', { status: 200 }));
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
checkAndUpdateBudgetStatus,
|
|
46
|
+
checkMonthlyBudgets,
|
|
47
|
+
} from '../../../workers/lib/usage/queue/budget-enforcement';
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Helpers
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
function createTestEnv(kvData: Record<string, string> = {}, slackUrl?: string) {
|
|
54
|
+
const db = new MockD1Database();
|
|
55
|
+
db.queueRunResult({ success: true });
|
|
56
|
+
db.queueRunResult({ success: true });
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
env: {
|
|
60
|
+
PLATFORM_CACHE: createMockKV(kvData),
|
|
61
|
+
PLATFORM_DB: db,
|
|
62
|
+
SLACK_WEBHOOK_URL: slackUrl,
|
|
63
|
+
NOTIFICATIONS_API: undefined,
|
|
64
|
+
PLATFORM_TELEMETRY: undefined,
|
|
65
|
+
TELEMETRY_QUEUE: undefined,
|
|
66
|
+
} as unknown,
|
|
67
|
+
db,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ===========================================================================
|
|
72
|
+
// Section A: Feature-Level Budget Warnings
|
|
73
|
+
// ===========================================================================
|
|
74
|
+
|
|
75
|
+
describe('checkAndUpdateBudgetStatus', () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
78
|
+
mockFetch.mockClear();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.unstubAllGlobals();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('fires 70% warning when metric is between 70-89% of budget', async () => {
|
|
86
|
+
const { env } = createTestEnv(
|
|
87
|
+
{
|
|
88
|
+
'CONFIG:FEATURE:test:api:endpoint:BUDGET': JSON.stringify({
|
|
89
|
+
d1_rows_written: 100_000,
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
'https://hooks.slack.com/test'
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await checkAndUpdateBudgetStatus(
|
|
96
|
+
'test:api:endpoint',
|
|
97
|
+
{ d1RowsWritten: 72_000 } as never,
|
|
98
|
+
env as never
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const kv = (env as Record<string, unknown>).PLATFORM_CACHE as ReturnType<typeof createMockKV>;
|
|
102
|
+
expect(kv.put).toHaveBeenCalledWith(
|
|
103
|
+
'BUDGET_WARN:test:api:endpoint:d1RowsWritten:70',
|
|
104
|
+
'1',
|
|
105
|
+
{ expirationTtl: 3600 }
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('fires 90% critical warning when metric >= 90% of budget', async () => {
|
|
110
|
+
const { env } = createTestEnv(
|
|
111
|
+
{
|
|
112
|
+
'CONFIG:FEATURE:test:api:endpoint:BUDGET': JSON.stringify({
|
|
113
|
+
d1_rows_written: 100_000,
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
'https://hooks.slack.com/test'
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
await checkAndUpdateBudgetStatus(
|
|
120
|
+
'test:api:endpoint',
|
|
121
|
+
{ d1RowsWritten: 92_000 } as never,
|
|
122
|
+
env as never
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const kv = (env as Record<string, unknown>).PLATFORM_CACHE as ReturnType<typeof createMockKV>;
|
|
126
|
+
expect(kv.put).toHaveBeenCalledWith(
|
|
127
|
+
'BUDGET_WARN:test:api:endpoint:d1RowsWritten:90',
|
|
128
|
+
'1',
|
|
129
|
+
{ expirationTtl: 3600 }
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('does not fire warning when metric is below 70%', async () => {
|
|
134
|
+
const { env } = createTestEnv(
|
|
135
|
+
{
|
|
136
|
+
'CONFIG:FEATURE:test:api:endpoint:BUDGET': JSON.stringify({
|
|
137
|
+
d1_rows_written: 100_000,
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
'https://hooks.slack.com/test'
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
await checkAndUpdateBudgetStatus(
|
|
144
|
+
'test:api:endpoint',
|
|
145
|
+
{ d1RowsWritten: 50_000 } as never,
|
|
146
|
+
env as never
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('skips warning when dedup key already exists', async () => {
|
|
153
|
+
const { env } = createTestEnv(
|
|
154
|
+
{
|
|
155
|
+
'CONFIG:FEATURE:test:api:endpoint:BUDGET': JSON.stringify({
|
|
156
|
+
d1_rows_written: 100_000,
|
|
157
|
+
}),
|
|
158
|
+
'BUDGET_WARN:test:api:endpoint:d1RowsWritten:70': '1',
|
|
159
|
+
},
|
|
160
|
+
'https://hooks.slack.com/test'
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
await checkAndUpdateBudgetStatus(
|
|
164
|
+
'test:api:endpoint',
|
|
165
|
+
{ d1RowsWritten: 75_000 } as never,
|
|
166
|
+
env as never
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('does nothing when no budget config exists', async () => {
|
|
173
|
+
const { env } = createTestEnv({}, 'https://hooks.slack.com/test');
|
|
174
|
+
|
|
175
|
+
await checkAndUpdateBudgetStatus(
|
|
176
|
+
'test:api:endpoint',
|
|
177
|
+
{ d1RowsWritten: 99_000 } as never,
|
|
178
|
+
env as never
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ===========================================================================
|
|
186
|
+
// Section B: Monthly Budget Tracking
|
|
187
|
+
// ===========================================================================
|
|
188
|
+
|
|
189
|
+
describe('checkMonthlyBudgets', () => {
|
|
190
|
+
beforeEach(() => {
|
|
191
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
192
|
+
mockFetch.mockClear();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
afterEach(() => {
|
|
196
|
+
vi.unstubAllGlobals();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('queries daily_usage_rollups for current month', async () => {
|
|
200
|
+
const { env, db } = createTestEnv({});
|
|
201
|
+
db.queueAllResult([]);
|
|
202
|
+
|
|
203
|
+
await checkMonthlyBudgets(env as never);
|
|
204
|
+
|
|
205
|
+
expect(db.statements.some((s: string) => s.includes('daily_usage_rollups'))).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('handles missing tables gracefully', async () => {
|
|
209
|
+
const { env } = createTestEnv({});
|
|
210
|
+
|
|
211
|
+
// Should not throw even with empty DB
|
|
212
|
+
await expect(checkMonthlyBudgets(env as never)).resolves.not.toThrow();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
include: ['tests/**/*.test.ts'],
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: 'v8',
|
|
10
|
+
include: ['workers/**/*.ts'],
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
resolve: {
|
|
14
|
+
alias: {
|
|
15
|
+
'@': './src',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { EmptyState } from '../../components/ui/EmptyState';
|
|
3
|
+
import { LoadingSkeleton } from '../../components/ui/LoadingSkeleton';
|
|
4
|
+
|
|
5
|
+
interface CBEvent {
|
|
6
|
+
id: number;
|
|
7
|
+
feature_key: string;
|
|
8
|
+
event_type: string;
|
|
9
|
+
old_status: string;
|
|
10
|
+
new_status: string;
|
|
11
|
+
reason: string | null;
|
|
12
|
+
timestamp: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function CircuitBreakerEvents() {
|
|
16
|
+
const [events, setEvents] = useState<CBEvent[]>([]);
|
|
17
|
+
const [loading, setLoading] = useState(true);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
fetch('/api/usage/circuit-breakers')
|
|
21
|
+
.then(res => res.json())
|
|
22
|
+
.then((data: { events?: CBEvent[] }) => { setEvents(data.events ?? []); setLoading(false); })
|
|
23
|
+
.catch(() => setLoading(false));
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
if (loading) return <LoadingSkeleton lines={3} />;
|
|
27
|
+
|
|
28
|
+
if (events.length === 0) {
|
|
29
|
+
return (
|
|
30
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
31
|
+
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
|
32
|
+
CB Event History
|
|
33
|
+
</h3>
|
|
34
|
+
<EmptyState title="No events" description="No circuit breaker state changes recorded." />
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
41
|
+
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
|
42
|
+
CB Event History
|
|
43
|
+
</h3>
|
|
44
|
+
<div className="space-y-2">
|
|
45
|
+
{events.map(ev => (
|
|
46
|
+
<div key={ev.id} className="flex items-start gap-3 py-1.5 border-b border-gray-100 dark:border-gray-700 last:border-0">
|
|
47
|
+
<div className={`mt-1 w-2 h-2 rounded-full shrink-0 ${
|
|
48
|
+
ev.new_status === 'active' ? 'bg-green-500'
|
|
49
|
+
: ev.new_status === 'warning' ? 'bg-yellow-500'
|
|
50
|
+
: 'bg-red-500'
|
|
51
|
+
}`} />
|
|
52
|
+
<div className="min-w-0">
|
|
53
|
+
<p className="text-sm text-gray-900 dark:text-white">
|
|
54
|
+
<span className="font-medium">{ev.feature_key}</span>
|
|
55
|
+
{' '}{ev.old_status} → {ev.new_status}
|
|
56
|
+
</p>
|
|
57
|
+
{ev.reason && (
|
|
58
|
+
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{ev.reason}</p>
|
|
59
|
+
)}
|
|
60
|
+
<p className="text-xs text-gray-400 dark:text-gray-500">
|
|
61
|
+
{new Date(ev.timestamp).toLocaleString()}
|
|
62
|
+
</p>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { LoadingSkeleton } from '../../components/ui/LoadingSkeleton';
|
|
3
|
+
|
|
4
|
+
interface CircuitBreaker {
|
|
5
|
+
key: string;
|
|
6
|
+
feature: string;
|
|
7
|
+
status: string;
|
|
8
|
+
reason: string | null;
|
|
9
|
+
level: 'global' | 'project' | 'feature';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function CircuitBreakerPanel() {
|
|
13
|
+
const [breakers, setBreakers] = useState<CircuitBreaker[]>([]);
|
|
14
|
+
const [loading, setLoading] = useState(true);
|
|
15
|
+
|
|
16
|
+
const loadBreakers = useCallback(() => {
|
|
17
|
+
fetch('/api/usage/circuit-breakers')
|
|
18
|
+
.then(res => res.json())
|
|
19
|
+
.then((data: { breakers: CircuitBreaker[] }) => {
|
|
20
|
+
const enriched = (data.breakers ?? []).map(cb => ({
|
|
21
|
+
...cb,
|
|
22
|
+
level: (cb.key.includes(':global:') ? 'global'
|
|
23
|
+
: cb.key.includes(':project:') ? 'project'
|
|
24
|
+
: 'feature') as CircuitBreaker['level'],
|
|
25
|
+
}));
|
|
26
|
+
setBreakers(enriched);
|
|
27
|
+
setLoading(false);
|
|
28
|
+
})
|
|
29
|
+
.catch(() => setLoading(false));
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
useEffect(() => { loadBreakers(); }, [loadBreakers]);
|
|
33
|
+
|
|
34
|
+
if (loading) return <LoadingSkeleton lines={4} />;
|
|
35
|
+
|
|
36
|
+
const grouped = {
|
|
37
|
+
global: breakers.filter(b => b.level === 'global'),
|
|
38
|
+
project: breakers.filter(b => b.level === 'project'),
|
|
39
|
+
feature: breakers.filter(b => b.level === 'feature'),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const statusBadge = (status: string) => {
|
|
43
|
+
const styles = status === 'active'
|
|
44
|
+
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
|
45
|
+
: status === 'warning'
|
|
46
|
+
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
|
47
|
+
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
|
48
|
+
return <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles}`}>{status}</span>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const renderGroup = (title: string, items: CircuitBreaker[]) => {
|
|
52
|
+
if (items.length === 0) return null;
|
|
53
|
+
return (
|
|
54
|
+
<div>
|
|
55
|
+
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">{title}</h4>
|
|
56
|
+
<div className="space-y-1.5">
|
|
57
|
+
{items.map(cb => (
|
|
58
|
+
<div key={cb.key} className="flex items-center justify-between py-1.5">
|
|
59
|
+
<div>
|
|
60
|
+
<span className="text-sm text-gray-900 dark:text-white">{cb.feature}</span>
|
|
61
|
+
{cb.reason && (
|
|
62
|
+
<p className="text-xs text-gray-500 dark:text-gray-400">{cb.reason}</p>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
{statusBadge(cb.status)}
|
|
66
|
+
</div>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
75
|
+
<div className="flex items-center justify-between mb-4">
|
|
76
|
+
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
|
77
|
+
Circuit Breakers
|
|
78
|
+
</h3>
|
|
79
|
+
<button
|
|
80
|
+
onClick={loadBreakers}
|
|
81
|
+
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
|
82
|
+
>
|
|
83
|
+
Refresh
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
{breakers.length === 0 ? (
|
|
87
|
+
<p className="text-sm text-gray-500 dark:text-gray-400">No circuit breakers configured.</p>
|
|
88
|
+
) : (
|
|
89
|
+
<div className="space-y-4">
|
|
90
|
+
{renderGroup('Global', grouped.global)}
|
|
91
|
+
{renderGroup('Project', grouped.project)}
|
|
92
|
+
{renderGroup('Feature', grouped.feature)}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
interface Env {
|
|
4
|
+
PLATFORM_DB?: D1Database;
|
|
5
|
+
PLATFORM_CACHE?: KVNamespace;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const POST: APIRoute = async ({ locals, params }) => {
|
|
9
|
+
const env = locals.runtime?.env as Env | undefined;
|
|
10
|
+
const db = env?.PLATFORM_DB;
|
|
11
|
+
const kv = env?.PLATFORM_CACHE;
|
|
12
|
+
const fingerprint = params.fingerprint;
|
|
13
|
+
|
|
14
|
+
if (!fingerprint || !db) {
|
|
15
|
+
return new Response(JSON.stringify({ error: 'Missing fingerprint or database' }), {
|
|
16
|
+
status: 400,
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await db
|
|
23
|
+
.prepare(
|
|
24
|
+
`UPDATE error_occurrences
|
|
25
|
+
SET labels = CASE
|
|
26
|
+
WHEN labels IS NULL THEN 'cf:muted'
|
|
27
|
+
WHEN labels NOT LIKE '%cf:muted%' THEN labels || ',cf:muted'
|
|
28
|
+
ELSE labels
|
|
29
|
+
END
|
|
30
|
+
WHERE fingerprint = ?`
|
|
31
|
+
)
|
|
32
|
+
.bind(fingerprint)
|
|
33
|
+
.run();
|
|
34
|
+
|
|
35
|
+
// Also set KV flag for error-collector to skip
|
|
36
|
+
if (kv) {
|
|
37
|
+
await kv.put(`ERROR_FINGERPRINT:${fingerprint}:MUTED`, '1', { expirationTtl: 86400 * 30 });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
});
|
|
43
|
+
} catch {
|
|
44
|
+
return new Response(JSON.stringify({ error: 'Failed to mute error' }), {
|
|
45
|
+
status: 500,
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
interface Env {
|
|
4
|
+
PLATFORM_DB?: D1Database;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const POST: APIRoute = async ({ locals, params }) => {
|
|
8
|
+
const env = locals.runtime?.env as Env | undefined;
|
|
9
|
+
const db = env?.PLATFORM_DB;
|
|
10
|
+
const fingerprint = params.fingerprint;
|
|
11
|
+
|
|
12
|
+
if (!fingerprint || !db) {
|
|
13
|
+
return new Response(JSON.stringify({ error: 'Missing fingerprint or database' }), {
|
|
14
|
+
status: 400,
|
|
15
|
+
headers: { 'Content-Type': 'application/json' },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
await db
|
|
21
|
+
.prepare(
|
|
22
|
+
`UPDATE error_occurrences SET status = 'resolved' WHERE fingerprint = ?`
|
|
23
|
+
)
|
|
24
|
+
.bind(fingerprint)
|
|
25
|
+
.run();
|
|
26
|
+
|
|
27
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
});
|
|
30
|
+
} catch {
|
|
31
|
+
return new Response(JSON.stringify({ error: 'Failed to resolve error' }), {
|
|
32
|
+
status: 500,
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
interface Env {
|
|
4
|
+
PLATFORM_DB?: D1Database;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const GET: APIRoute = async ({ locals, params }) => {
|
|
8
|
+
const env = locals.runtime?.env as Env | undefined;
|
|
9
|
+
const db = env?.PLATFORM_DB;
|
|
10
|
+
const fingerprint = params.fingerprint;
|
|
11
|
+
|
|
12
|
+
if (!fingerprint) {
|
|
13
|
+
return new Response(JSON.stringify({ error: 'Missing fingerprint' }), {
|
|
14
|
+
status: 400,
|
|
15
|
+
headers: { 'Content-Type': 'application/json' },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!db) {
|
|
20
|
+
return new Response(JSON.stringify({ error: 'Database not available' }), {
|
|
21
|
+
status: 503,
|
|
22
|
+
headers: { 'Content-Type': 'application/json' },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const row = await db
|
|
28
|
+
.prepare(
|
|
29
|
+
`SELECT fingerprint, normalized_message, script_name, priority, status,
|
|
30
|
+
occurrence_count, first_seen_at, last_seen_at, github_issue_url,
|
|
31
|
+
labels, error_category
|
|
32
|
+
FROM error_occurrences
|
|
33
|
+
WHERE fingerprint = ?
|
|
34
|
+
LIMIT 1`
|
|
35
|
+
)
|
|
36
|
+
.bind(fingerprint)
|
|
37
|
+
.first();
|
|
38
|
+
|
|
39
|
+
if (!row) {
|
|
40
|
+
return new Response(JSON.stringify({ error: 'Not found' }), {
|
|
41
|
+
status: 404,
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return new Response(JSON.stringify(row), {
|
|
47
|
+
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=30' },
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return new Response(JSON.stringify({ error: 'Failed to fetch error detail' }), {
|
|
51
|
+
status: 500,
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const GET: APIRoute = async ({ locals, url }) => {
|
|
4
|
+
const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
|
|
5
|
+
|
|
6
|
+
if (!db) {
|
|
7
|
+
return new Response(JSON.stringify({ history: [] }), {
|
|
8
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '20'), 100);
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const history = await db
|
|
16
|
+
.prepare(
|
|
17
|
+
`SELECT id, project, scan_type, ai_judge_score, sdk_score,
|
|
18
|
+
observability_score, cost_protection_score, security_score,
|
|
19
|
+
scan_date
|
|
20
|
+
FROM audit_results
|
|
21
|
+
ORDER BY scan_date DESC
|
|
22
|
+
LIMIT ?`
|
|
23
|
+
)
|
|
24
|
+
.bind(limit)
|
|
25
|
+
.all();
|
|
26
|
+
|
|
27
|
+
return new Response(
|
|
28
|
+
JSON.stringify({ history: history.results ?? [] }),
|
|
29
|
+
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=120' } }
|
|
30
|
+
);
|
|
31
|
+
} catch {
|
|
32
|
+
return new Response(JSON.stringify({ history: [] }), {
|
|
33
|
+
status: 500,
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import DashboardLayout from '../layouts/DashboardLayout.astro';
|
|
3
|
+
import { CircuitBreakerPanel } from '../components/health/CircuitBreakerPanel';
|
|
4
|
+
import { CircuitBreakerEvents } from '../components/health/CircuitBreakerEvents';
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<DashboardLayout title="Circuit Breakers">
|
|
8
|
+
<div class="max-w-5xl mx-auto space-y-4">
|
|
9
|
+
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Circuit Breakers</h2>
|
|
10
|
+
<CircuitBreakerPanel client:load />
|
|
11
|
+
<CircuitBreakerEvents client:load />
|
|
12
|
+
</div>
|
|
13
|
+
</DashboardLayout>
|