@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.
Files changed (86) hide show
  1. package/dist/templates.d.ts +1 -1
  2. package/dist/templates.js +112 -1
  3. package/package.json +1 -1
  4. package/templates/full/dashboard/src/components/patterns/ActivePatterns.tsx +62 -0
  5. package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
  6. package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
  7. package/templates/full/dashboard/src/components/patterns/index.ts +3 -0
  8. package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
  9. package/templates/full/dashboard/src/components/reports/SdkAuditReport.tsx +72 -0
  10. package/templates/full/dashboard/src/components/reports/index.ts +2 -0
  11. package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
  12. package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -0
  13. package/templates/full/dashboard/src/pages/api/patterns/cache-refresh.ts +38 -0
  14. package/templates/full/dashboard/src/pages/api/patterns/discover.ts +36 -0
  15. package/templates/full/dashboard/src/pages/api/patterns/ready-for-review.ts +39 -0
  16. package/templates/full/dashboard/src/pages/api/patterns/stats.ts +39 -0
  17. package/templates/full/dashboard/src/pages/api/patterns/suggestions.ts +43 -0
  18. package/templates/full/dashboard/src/pages/api/reports/audit.ts +45 -0
  19. package/templates/full/dashboard/src/pages/api/reports/usage.ts +52 -0
  20. package/templates/full/dashboard/src/pages/api/search/reindex.ts +28 -0
  21. package/templates/full/dashboard/src/pages/api/search/stats.ts +27 -0
  22. package/templates/full/dashboard/src/pages/api/settings/index.ts +37 -0
  23. package/templates/full/dashboard/src/pages/api/settings/update.ts +41 -0
  24. package/templates/full/dashboard/src/pages/api/topology/index.ts +56 -0
  25. package/templates/full/scripts/ops/universal-backfill.ts +147 -0
  26. package/templates/shared/.github/workflows/contract-check.yml.hbs +42 -0
  27. package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
  28. package/templates/shared/.github/workflows/security.yml +33 -0
  29. package/templates/shared/dashboard/src/components/Nav.astro.hbs +2 -0
  30. package/templates/shared/dashboard/src/components/infrastructure/AlertHistory.tsx +57 -0
  31. package/templates/shared/dashboard/src/components/infrastructure/InfrastructureStats.tsx +73 -0
  32. package/templates/shared/dashboard/src/components/infrastructure/ServiceRegistry.tsx +55 -0
  33. package/templates/shared/dashboard/src/components/infrastructure/UptimeStatus.tsx +56 -0
  34. package/templates/shared/dashboard/src/components/infrastructure/index.ts +4 -0
  35. package/templates/shared/dashboard/src/components/ui/Breadcrumbs.tsx +27 -0
  36. package/templates/shared/dashboard/src/components/ui/EmptyState.tsx +26 -0
  37. package/templates/shared/dashboard/src/components/ui/ErrorBoundary.tsx +42 -0
  38. package/templates/shared/dashboard/src/components/ui/LoadingSkeleton.tsx +18 -0
  39. package/templates/shared/dashboard/src/components/ui/PageShell.tsx +26 -0
  40. package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
  41. package/templates/shared/dashboard/src/components/ui/index.ts +6 -0
  42. package/templates/shared/dashboard/src/components/usage/AnomaliesWidget.tsx +68 -0
  43. package/templates/shared/dashboard/src/components/usage/HourlyUsageChart.tsx +55 -0
  44. package/templates/shared/dashboard/src/components/usage/PlanAllowanceDashboard.tsx +67 -0
  45. package/templates/shared/dashboard/src/components/usage/ProjectCostBreakdown.tsx +55 -0
  46. package/templates/shared/dashboard/src/components/usage/index.ts +4 -0
  47. package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -0
  48. package/templates/shared/dashboard/src/pages/api/costs/overview.ts +65 -0
  49. package/templates/shared/dashboard/src/pages/api/costs/providers.ts +47 -0
  50. package/templates/shared/dashboard/src/pages/api/infrastructure/services.ts +55 -0
  51. package/templates/shared/dashboard/src/pages/api/infrastructure/stats.ts +99 -0
  52. package/templates/shared/dashboard/src/pages/api/usage/allowances.ts +56 -0
  53. package/templates/shared/dashboard/src/pages/api/usage/anomalies.ts +45 -0
  54. package/templates/shared/dashboard/src/pages/api/usage/billing.ts +53 -0
  55. package/templates/shared/dashboard/src/pages/api/usage/granular.ts +50 -0
  56. package/templates/shared/dashboard/src/pages/api/usage/hourly.ts +45 -0
  57. package/templates/shared/dashboard/src/pages/api/usage/projects.ts +51 -0
  58. package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -0
  59. package/templates/shared/dashboard/src/pages/settings/notifications.astro +34 -0
  60. package/templates/shared/dashboard/src/pages/settings/thresholds.astro +39 -0
  61. package/templates/shared/dashboard/src/pages/settings/usage.astro +28 -0
  62. package/templates/shared/docs/architecture.md +89 -0
  63. package/templates/shared/docs/post-deploy-runbook.md +126 -0
  64. package/templates/shared/docs/troubleshooting.md +91 -0
  65. package/templates/shared/package.json.hbs +5 -0
  66. package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
  67. package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -0
  68. package/templates/shared/scripts/ops/validate-controls.js +141 -0
  69. package/templates/shared/tests/contract/validate-schemas.test.ts +130 -0
  70. package/templates/shared/tests/fixtures/telemetry-envelope-invalid.json +9 -0
  71. package/templates/shared/tests/fixtures/telemetry-envelope-valid.json +27 -0
  72. package/templates/shared/tests/helpers/mock-d1.ts +61 -0
  73. package/templates/shared/tests/helpers/mock-kv.ts +37 -0
  74. package/templates/shared/tests/unit/workers/batch-persistence.test.ts +133 -0
  75. package/templates/shared/tests/unit/workers/budget-enforcement.test.ts +214 -0
  76. package/templates/shared/vitest.config.ts +18 -0
  77. package/templates/standard/dashboard/src/components/health/CircuitBreakerEvents.tsx +69 -0
  78. package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -0
  79. package/templates/standard/dashboard/src/components/health/index.ts +2 -0
  80. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/mute.ts +49 -0
  81. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/resolve.ts +36 -0
  82. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint].ts +55 -0
  83. package/templates/standard/dashboard/src/pages/api/health/audit-history.ts +37 -0
  84. package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -0
  85. package/templates/standard/tests/unit/error-collector/capture.test.ts +106 -0
  86. 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} &rarr; {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
+ }
@@ -1,2 +1,4 @@
1
1
  export { HealthTabs } from './HealthTabs';
2
2
  export { DlqStatusCard } from './DlqStatusCard';
3
+ export { CircuitBreakerPanel } from './CircuitBreakerPanel';
4
+ export { CircuitBreakerEvents } from './CircuitBreakerEvents';
@@ -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>