@littlebearapps/platform-admin-sdk 1.5.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 +197 -2
- 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/DigestStats.tsx +151 -0
- package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
- package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -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/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/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/reports/ReportInfoButton.tsx +98 -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/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/costs.ts +21 -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/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/e2e/usage-api.test.ts +909 -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/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/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/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/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/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/capture.test.ts +106 -0
- package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
- package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -0
- package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure API Client
|
|
3
|
+
* Client functions for fetching infrastructure data
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
Service,
|
|
8
|
+
ServiceRegistryStats,
|
|
9
|
+
UptimeMonitor,
|
|
10
|
+
HealthcheckJob,
|
|
11
|
+
Alert,
|
|
12
|
+
InfrastructureStats,
|
|
13
|
+
FlipEvent,
|
|
14
|
+
ResponseTimeDataPoint,
|
|
15
|
+
ResponseTimeStats,
|
|
16
|
+
} from './types';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fetch all services from the service registry
|
|
20
|
+
*/
|
|
21
|
+
export async function fetchServices(): Promise<Service[]> {
|
|
22
|
+
const response = await fetch('/api/infrastructure/services');
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(`Failed to fetch services: ${response.statusText}`);
|
|
25
|
+
}
|
|
26
|
+
const data = (await response.json()) as { services?: Service[] };
|
|
27
|
+
return data.services || [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fetch service registry statistics
|
|
32
|
+
*/
|
|
33
|
+
export async function fetchServiceStats(): Promise<ServiceRegistryStats> {
|
|
34
|
+
const response = await fetch('/api/infrastructure/services/stats');
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`Failed to fetch service stats: ${response.statusText}`);
|
|
37
|
+
}
|
|
38
|
+
return response.json();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fetch Gatus uptime monitors
|
|
43
|
+
*/
|
|
44
|
+
export async function fetchUptimeMonitors(): Promise<UptimeMonitor[]> {
|
|
45
|
+
const response = await fetch('/api/infrastructure/uptime');
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new Error(`Failed to fetch uptime monitors: ${response.statusText}`);
|
|
48
|
+
}
|
|
49
|
+
const data = (await response.json()) as { monitors?: UptimeMonitor[] };
|
|
50
|
+
return data.monitors || [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fetch Gatus heartbeats
|
|
55
|
+
*/
|
|
56
|
+
export async function fetchHealthchecks(): Promise<HealthcheckJob[]> {
|
|
57
|
+
const response = await fetch('/api/infrastructure/healthchecks');
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new Error(`Failed to fetch healthchecks: ${response.statusText}`);
|
|
60
|
+
}
|
|
61
|
+
const data = (await response.json()) as { checks?: HealthcheckJob[] };
|
|
62
|
+
return data.checks || [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Flip history response from the API
|
|
67
|
+
*/
|
|
68
|
+
export interface FlipsResponse {
|
|
69
|
+
flips: FlipEvent[];
|
|
70
|
+
checkId: string;
|
|
71
|
+
cached: boolean;
|
|
72
|
+
flipsToday: number;
|
|
73
|
+
lastFailure: number | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fetch flip history (status changes) for a specific healthcheck
|
|
78
|
+
*/
|
|
79
|
+
export async function fetchHealthcheckFlips(checkId: string): Promise<FlipsResponse> {
|
|
80
|
+
const response = await fetch(`/api/infrastructure/healthchecks/${checkId}/flips`);
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new Error(`Failed to fetch flips: ${response.statusText}`);
|
|
83
|
+
}
|
|
84
|
+
return response.json();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Fetch recent alerts from D1
|
|
89
|
+
*/
|
|
90
|
+
export async function fetchAlerts(limit: number = 20): Promise<Alert[]> {
|
|
91
|
+
const response = await fetch(`/api/infrastructure/alerts?limit=${limit}`);
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
throw new Error(`Failed to fetch alerts: ${response.statusText}`);
|
|
94
|
+
}
|
|
95
|
+
const data = (await response.json()) as { alerts?: Alert[] };
|
|
96
|
+
return data.alerts || [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Acknowledge an alert
|
|
101
|
+
*/
|
|
102
|
+
export async function acknowledgeAlert(id: string): Promise<void> {
|
|
103
|
+
const response = await fetch(`/api/infrastructure/alerts/${id}/acknowledge`, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
});
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(`Failed to acknowledge alert: ${response.statusText}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Fetch combined infrastructure statistics
|
|
113
|
+
*/
|
|
114
|
+
export async function fetchInfrastructureStats(): Promise<InfrastructureStats> {
|
|
115
|
+
const response = await fetch('/api/infrastructure/stats');
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
throw new Error(`Failed to fetch infrastructure stats: ${response.statusText}`);
|
|
118
|
+
}
|
|
119
|
+
return response.json();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Response times response from the API
|
|
124
|
+
*/
|
|
125
|
+
export interface ResponseTimesResponse {
|
|
126
|
+
dataPoints: ResponseTimeDataPoint[];
|
|
127
|
+
monitorId: string;
|
|
128
|
+
cached: boolean;
|
|
129
|
+
stats: ResponseTimeStats;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Fetch response time history for a specific Gatus monitor
|
|
134
|
+
*/
|
|
135
|
+
export async function fetchUptimeResponseTimes(monitorId: string): Promise<ResponseTimesResponse> {
|
|
136
|
+
const response = await fetch(`/api/infrastructure/uptime/${monitorId}/response-times`);
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
throw new Error(`Failed to fetch response times: ${response.statusText}`);
|
|
139
|
+
}
|
|
140
|
+
return response.json();
|
|
141
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gatus API Client
|
|
3
|
+
* Shared fetch + KV cache layer for Gatus self-hosted monitoring
|
|
4
|
+
* https://{{gatusUrl}}
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A single check result from Gatus */
|
|
8
|
+
export interface GatusResult {
|
|
9
|
+
status: number;
|
|
10
|
+
hostname: string;
|
|
11
|
+
duration: number; // nanoseconds
|
|
12
|
+
success: boolean;
|
|
13
|
+
timestamp: string; // ISO 8601
|
|
14
|
+
errors: string[];
|
|
15
|
+
conditionResults: Array<{
|
|
16
|
+
condition: string;
|
|
17
|
+
success: boolean;
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A status transition event (only on single-endpoint responses) */
|
|
22
|
+
export interface GatusEvent {
|
|
23
|
+
type: 'START' | 'HEALTHY' | 'UNHEALTHY';
|
|
24
|
+
timestamp: string; // ISO 8601
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A single endpoint status from Gatus.
|
|
29
|
+
* Bulk endpoint (/statuses) returns only results.
|
|
30
|
+
* Single endpoint (/{key}/statuses) also includes events.
|
|
31
|
+
*/
|
|
32
|
+
export interface GatusEndpointStatus {
|
|
33
|
+
name: string;
|
|
34
|
+
group: string;
|
|
35
|
+
key: string;
|
|
36
|
+
results: GatusResult[];
|
|
37
|
+
events?: GatusEvent[]; // Only present on single-endpoint responses
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Calculate uptime % from results (success count / total count * 100) */
|
|
41
|
+
export function calculateUptimeFromResults(results: GatusResult[]): number {
|
|
42
|
+
if (results.length === 0) return 0;
|
|
43
|
+
const successes = results.filter((r) => r.success).length;
|
|
44
|
+
return (successes / results.length) * 100;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CACHE_KEY = 'GATUS_ALL_STATUSES';
|
|
48
|
+
const CACHE_TTL = 300; // 5 minutes
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fetch all endpoint statuses from Gatus, with KV caching.
|
|
52
|
+
* CF Access is bypassed on /api/* so no auth is needed.
|
|
53
|
+
*/
|
|
54
|
+
export async function fetchAllGatusStatuses(
|
|
55
|
+
gatusUrl: string,
|
|
56
|
+
kv?: KVNamespace
|
|
57
|
+
): Promise<GatusEndpointStatus[]> {
|
|
58
|
+
// Check cache first
|
|
59
|
+
if (kv) {
|
|
60
|
+
const cached = await kv.get(CACHE_KEY, 'json');
|
|
61
|
+
if (cached && Array.isArray(cached)) {
|
|
62
|
+
return cached as GatusEndpointStatus[];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const response = await fetch(`${gatusUrl}/api/v1/endpoints/statuses`);
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
throw new Error(`Gatus API error: ${response.status}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const data = (await response.json()) as GatusEndpointStatus[];
|
|
72
|
+
|
|
73
|
+
// Cache the result
|
|
74
|
+
if (kv) {
|
|
75
|
+
await kv.put(CACHE_KEY, JSON.stringify(data), { expirationTtl: CACHE_TTL });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Fetch a single endpoint's status from Gatus, with KV caching.
|
|
83
|
+
*/
|
|
84
|
+
export async function fetchGatusEndpoint(
|
|
85
|
+
gatusUrl: string,
|
|
86
|
+
key: string,
|
|
87
|
+
kv?: KVNamespace
|
|
88
|
+
): Promise<GatusEndpointStatus> {
|
|
89
|
+
const cacheKey = `GATUS_ENDPOINT:${key}`;
|
|
90
|
+
|
|
91
|
+
// Check cache first
|
|
92
|
+
if (kv) {
|
|
93
|
+
const cached = await kv.get(cacheKey, 'json');
|
|
94
|
+
if (cached) {
|
|
95
|
+
return cached as GatusEndpointStatus;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const response = await fetch(`${gatusUrl}/api/v1/endpoints/${key}/statuses`);
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
throw new Error(`Gatus API error for ${key}: ${response.status}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const data = (await response.json()) as GatusEndpointStatus;
|
|
105
|
+
|
|
106
|
+
// Cache the result
|
|
107
|
+
if (kv) {
|
|
108
|
+
await kv.put(cacheKey, JSON.stringify(data), { expirationTtl: CACHE_TTL });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return data;
|
|
112
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Binding Proxy Module
|
|
3
|
+
*
|
|
4
|
+
* Exports utilities for proxying requests from Pages Functions to Workers
|
|
5
|
+
* via Cloudflare Service Bindings.
|
|
6
|
+
*
|
|
7
|
+
* @module services/proxy
|
|
8
|
+
* @created 2025-12-04
|
|
9
|
+
* @task task-159 - Implement thin proxy pattern for Brand Copilot Worker APIs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { proxyToService, proxyToServiceWithMetrics, createErrorResponse } from './proxy';
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
Fetcher,
|
|
16
|
+
ProxyOptions,
|
|
17
|
+
ProxyErrorResponse,
|
|
18
|
+
ProxyErrorCode,
|
|
19
|
+
ProxyResult,
|
|
20
|
+
} from './types';
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Binding Proxy Utility
|
|
3
|
+
*
|
|
4
|
+
* Generic proxy function for forwarding requests from Pages Functions
|
|
5
|
+
* to Workers via service bindings. Handles error handling, retries,
|
|
6
|
+
* and observability.
|
|
7
|
+
*
|
|
8
|
+
* @module services/proxy/proxy
|
|
9
|
+
* @created 2025-12-04
|
|
10
|
+
* @task task-159 - Implement thin proxy pattern for Brand Copilot Worker APIs
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
Fetcher,
|
|
15
|
+
ProxyOptions,
|
|
16
|
+
ProxyErrorResponse,
|
|
17
|
+
ProxyErrorCode,
|
|
18
|
+
ProxyResult,
|
|
19
|
+
} from './types';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Default proxy options
|
|
23
|
+
*/
|
|
24
|
+
const DEFAULT_OPTIONS: Required<Omit<ProxyOptions, 'onError' | 'requestId'>> = {
|
|
25
|
+
retries: 1,
|
|
26
|
+
retryDelay: 100,
|
|
27
|
+
logTiming: true,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Proxy a request to a Worker via service binding
|
|
32
|
+
*
|
|
33
|
+
* @param service - The service binding (Fetcher) to call
|
|
34
|
+
* @param request - The incoming request to proxy
|
|
35
|
+
* @param options - Optional configuration
|
|
36
|
+
* @returns The response from the Worker
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```typescript
|
|
40
|
+
* const response = await proxyToService(
|
|
41
|
+
* env.BRAND_COPILOT,
|
|
42
|
+
* request,
|
|
43
|
+
* { requestId: 'abc-123' }
|
|
44
|
+
* );
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export async function proxyToService(
|
|
48
|
+
service: Fetcher,
|
|
49
|
+
request: Request,
|
|
50
|
+
options?: ProxyOptions
|
|
51
|
+
): Promise<Response> {
|
|
52
|
+
const result = await proxyToServiceWithMetrics(service, request, options);
|
|
53
|
+
return result.response;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Proxy a request to a Worker with full metrics
|
|
58
|
+
*
|
|
59
|
+
* @param service - The service binding (Fetcher) to call
|
|
60
|
+
* @param request - The incoming request to proxy
|
|
61
|
+
* @param options - Optional configuration
|
|
62
|
+
* @returns ProxyResult with response and metrics
|
|
63
|
+
*/
|
|
64
|
+
export async function proxyToServiceWithMetrics(
|
|
65
|
+
service: Fetcher,
|
|
66
|
+
request: Request,
|
|
67
|
+
options?: ProxyOptions
|
|
68
|
+
): Promise<ProxyResult> {
|
|
69
|
+
const startTime = performance.now();
|
|
70
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
71
|
+
const requestId = opts.requestId ?? generateRequestId();
|
|
72
|
+
|
|
73
|
+
let lastError: Error | null = null;
|
|
74
|
+
let retriesUsed = 0;
|
|
75
|
+
|
|
76
|
+
// Create proxy request with filtered headers
|
|
77
|
+
const proxyRequest = createProxyRequest(request, requestId);
|
|
78
|
+
|
|
79
|
+
for (let attempt = 0; attempt <= opts.retries; attempt++) {
|
|
80
|
+
try {
|
|
81
|
+
const response = await service.fetch(proxyRequest.clone());
|
|
82
|
+
const duration = performance.now() - startTime;
|
|
83
|
+
|
|
84
|
+
if (opts.logTiming) {
|
|
85
|
+
logProxyRequest(request, response, duration, requestId, retriesUsed);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
response,
|
|
90
|
+
duration_ms: Math.round(duration),
|
|
91
|
+
request_id: requestId,
|
|
92
|
+
retries_used: retriesUsed,
|
|
93
|
+
};
|
|
94
|
+
} catch (error) {
|
|
95
|
+
lastError = error as Error;
|
|
96
|
+
retriesUsed = attempt + 1;
|
|
97
|
+
|
|
98
|
+
if (attempt < opts.retries) {
|
|
99
|
+
await sleep(opts.retryDelay);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// All retries exhausted
|
|
105
|
+
const duration = performance.now() - startTime;
|
|
106
|
+
|
|
107
|
+
if (opts.logTiming) {
|
|
108
|
+
console.error(
|
|
109
|
+
`[PROXY] ${request.method} ${new URL(request.url).pathname} → ERROR after ${retriesUsed} attempts (${Math.round(duration)}ms): ${lastError?.message}`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Allow custom error handling
|
|
114
|
+
if (options?.onError) {
|
|
115
|
+
const customResponse = options.onError(lastError!);
|
|
116
|
+
if (customResponse) {
|
|
117
|
+
return {
|
|
118
|
+
response: customResponse,
|
|
119
|
+
duration_ms: Math.round(duration),
|
|
120
|
+
request_id: requestId,
|
|
121
|
+
retries_used: retriesUsed,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Default error response
|
|
127
|
+
const errorResponse = createErrorResponse(
|
|
128
|
+
'SERVICE_UNAVAILABLE',
|
|
129
|
+
'The backend service is temporarily unavailable',
|
|
130
|
+
requestId,
|
|
131
|
+
lastError?.message
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
response: errorResponse,
|
|
136
|
+
duration_ms: Math.round(duration),
|
|
137
|
+
request_id: requestId,
|
|
138
|
+
retries_used: retriesUsed,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Create a proxy request with filtered headers
|
|
144
|
+
*
|
|
145
|
+
* Service bindings ignore the hostname, so we use a dummy base URL
|
|
146
|
+
* to satisfy the Request constructor requirement for absolute URLs.
|
|
147
|
+
* We preserve the original host via X-Forwarded-* headers for OAuth flows.
|
|
148
|
+
*/
|
|
149
|
+
function createProxyRequest(request: Request, requestId: string): Request {
|
|
150
|
+
const url = new URL(request.url);
|
|
151
|
+
const path = url.pathname + url.search;
|
|
152
|
+
|
|
153
|
+
// Service bindings ignore hostname - use dummy base for valid URL
|
|
154
|
+
const proxyUrl = new URL(path, 'https://service.internal');
|
|
155
|
+
|
|
156
|
+
// Filter headers - remove Cloudflare-specific ones
|
|
157
|
+
const headers = new Headers();
|
|
158
|
+
request.headers.forEach((value, key) => {
|
|
159
|
+
// Skip headers that shouldn't be proxied
|
|
160
|
+
if (
|
|
161
|
+
!key.startsWith('cf-') &&
|
|
162
|
+
key !== 'host' &&
|
|
163
|
+
key !== 'x-forwarded-for' &&
|
|
164
|
+
key !== 'x-real-ip'
|
|
165
|
+
) {
|
|
166
|
+
headers.set(key, value);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Add request ID for correlation
|
|
171
|
+
headers.set('x-request-id', requestId);
|
|
172
|
+
|
|
173
|
+
// Preserve original host/protocol for OAuth callbacks and URL construction
|
|
174
|
+
// These headers allow the Worker to know the original request origin
|
|
175
|
+
headers.set('x-forwarded-host', url.host);
|
|
176
|
+
headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
|
|
177
|
+
|
|
178
|
+
return new Request(proxyUrl.toString(), {
|
|
179
|
+
method: request.method,
|
|
180
|
+
headers,
|
|
181
|
+
body: request.body,
|
|
182
|
+
// Don't follow redirects - return them to the browser for OAuth flows
|
|
183
|
+
redirect: 'manual',
|
|
184
|
+
// @ts-expect-error - duplex is required for streaming bodies
|
|
185
|
+
duplex: 'half',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Create a standardised error response
|
|
191
|
+
*/
|
|
192
|
+
export function createErrorResponse(
|
|
193
|
+
code: ProxyErrorCode,
|
|
194
|
+
message: string,
|
|
195
|
+
requestId?: string,
|
|
196
|
+
details?: string
|
|
197
|
+
): Response {
|
|
198
|
+
const body: ProxyErrorResponse = {
|
|
199
|
+
success: false,
|
|
200
|
+
error: message,
|
|
201
|
+
code,
|
|
202
|
+
...(requestId && { request_id: requestId }),
|
|
203
|
+
...(details && { details }),
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return new Response(JSON.stringify(body), {
|
|
207
|
+
status: code === 'TIMEOUT' ? 504 : 503,
|
|
208
|
+
headers: {
|
|
209
|
+
'Content-Type': 'application/json',
|
|
210
|
+
...(requestId && { 'x-request-id': requestId }),
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Generate a unique request ID
|
|
217
|
+
*/
|
|
218
|
+
function generateRequestId(): string {
|
|
219
|
+
return crypto.randomUUID();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Log a proxy request
|
|
224
|
+
*/
|
|
225
|
+
function logProxyRequest(
|
|
226
|
+
request: Request,
|
|
227
|
+
response: Response,
|
|
228
|
+
durationMs: number,
|
|
229
|
+
requestId: string,
|
|
230
|
+
retries: number
|
|
231
|
+
): void {
|
|
232
|
+
const url = new URL(request.url);
|
|
233
|
+
const retryInfo = retries > 0 ? ` (${retries} retries)` : '';
|
|
234
|
+
console.log(
|
|
235
|
+
`[PROXY] ${request.method} ${url.pathname} → ${response.status} (${Math.round(durationMs)}ms)${retryInfo} [${requestId.slice(0, 8)}]`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Sleep for a given number of milliseconds
|
|
241
|
+
*/
|
|
242
|
+
function sleep(ms: number): Promise<void> {
|
|
243
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
244
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Binding Proxy Types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for service binding proxy utilities.
|
|
5
|
+
* Used for proxying requests from Pages Functions to Workers.
|
|
6
|
+
*
|
|
7
|
+
* @module services/proxy/types
|
|
8
|
+
* @created 2025-12-04
|
|
9
|
+
* @task task-159 - Implement thin proxy pattern for Brand Copilot Worker APIs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Cloudflare Service Binding Fetcher interface
|
|
14
|
+
* Represents a service binding that can be used to call another Worker
|
|
15
|
+
*/
|
|
16
|
+
export interface Fetcher {
|
|
17
|
+
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Options for the proxy function
|
|
22
|
+
*/
|
|
23
|
+
export interface ProxyOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Maximum number of retries on failure
|
|
26
|
+
* @default 1
|
|
27
|
+
*/
|
|
28
|
+
retries?: number;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Delay between retries in milliseconds
|
|
32
|
+
* @default 100
|
|
33
|
+
*/
|
|
34
|
+
retryDelay?: number;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Custom error handler - return a Response to override default error handling
|
|
38
|
+
*/
|
|
39
|
+
onError?: (error: Error) => Response | null;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Request ID for correlation (will be generated if not provided)
|
|
43
|
+
*/
|
|
44
|
+
requestId?: string;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Log timing information
|
|
48
|
+
* @default true
|
|
49
|
+
*/
|
|
50
|
+
logTiming?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Error response format from proxy
|
|
55
|
+
*/
|
|
56
|
+
export interface ProxyErrorResponse {
|
|
57
|
+
success: false;
|
|
58
|
+
error: string;
|
|
59
|
+
code: ProxyErrorCode;
|
|
60
|
+
request_id?: string;
|
|
61
|
+
details?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Proxy error codes
|
|
66
|
+
*/
|
|
67
|
+
export type ProxyErrorCode =
|
|
68
|
+
| 'SERVICE_BINDING_ERROR'
|
|
69
|
+
| 'SERVICE_UNAVAILABLE'
|
|
70
|
+
| 'TIMEOUT'
|
|
71
|
+
| 'RETRY_EXHAUSTED';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Result of a proxy operation
|
|
75
|
+
*/
|
|
76
|
+
export interface ProxyResult {
|
|
77
|
+
response: Response;
|
|
78
|
+
duration_ms: number;
|
|
79
|
+
request_id: string;
|
|
80
|
+
retries_used: number;
|
|
81
|
+
}
|
|
@@ -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
|
+
};
|