@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,38 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const POST: APIRoute = async ({ locals }) => {
|
|
4
|
+
const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
|
|
5
|
+
const kv = (locals.runtime?.env as { PLATFORM_CACHE?: KVNamespace } | undefined)?.PLATFORM_CACHE;
|
|
6
|
+
|
|
7
|
+
if (!db || !kv) {
|
|
8
|
+
return new Response(JSON.stringify({ error: 'Bindings not available' }), {
|
|
9
|
+
status: 503,
|
|
10
|
+
headers: { 'Content-Type': 'application/json' },
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const approved = await db
|
|
16
|
+
.prepare(
|
|
17
|
+
`SELECT id, pattern_type, pattern_value, error_type, priority
|
|
18
|
+
FROM transient_pattern_suggestions
|
|
19
|
+
WHERE status = 'approved'
|
|
20
|
+
ORDER BY match_count DESC
|
|
21
|
+
LIMIT 500`
|
|
22
|
+
)
|
|
23
|
+
.all();
|
|
24
|
+
|
|
25
|
+
const patterns = approved.results ?? [];
|
|
26
|
+
await kv.put('PATTERNS:DYNAMIC:APPROVED', JSON.stringify(patterns), { expirationTtl: 86400 });
|
|
27
|
+
|
|
28
|
+
return new Response(
|
|
29
|
+
JSON.stringify({ refreshed: true, count: patterns.length }),
|
|
30
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
31
|
+
);
|
|
32
|
+
} catch {
|
|
33
|
+
return new Response(JSON.stringify({ error: 'Cache refresh failed' }), {
|
|
34
|
+
status: 500,
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const POST: APIRoute = async ({ locals }) => {
|
|
4
|
+
const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
|
|
5
|
+
|
|
6
|
+
if (!db) {
|
|
7
|
+
return new Response(JSON.stringify({ error: 'Database not available' }), {
|
|
8
|
+
status: 503,
|
|
9
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const unclassified = await db
|
|
15
|
+
.prepare(
|
|
16
|
+
`SELECT fingerprint, script_name, error_message, priority, occurrence_count
|
|
17
|
+
FROM error_occurrences
|
|
18
|
+
WHERE fingerprint NOT IN (
|
|
19
|
+
SELECT pattern_value FROM transient_pattern_suggestions WHERE status = 'approved'
|
|
20
|
+
)
|
|
21
|
+
ORDER BY occurrence_count DESC
|
|
22
|
+
LIMIT 50`
|
|
23
|
+
)
|
|
24
|
+
.all();
|
|
25
|
+
|
|
26
|
+
return new Response(
|
|
27
|
+
JSON.stringify({ discovered: unclassified.results?.length ?? 0, errors: unclassified.results ?? [] }),
|
|
28
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
29
|
+
);
|
|
30
|
+
} catch {
|
|
31
|
+
return new Response(JSON.stringify({ error: 'Discovery failed' }), {
|
|
32
|
+
status: 500,
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const GET: APIRoute = async ({ locals }) => {
|
|
4
|
+
const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
|
|
5
|
+
|
|
6
|
+
if (!db) {
|
|
7
|
+
return new Response(JSON.stringify({ patterns: [], count: 0 }), {
|
|
8
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const patterns = await db
|
|
14
|
+
.prepare(
|
|
15
|
+
`SELECT s.id, s.pattern_type, s.pattern_value, s.error_type, s.priority,
|
|
16
|
+
s.match_count, s.source, s.created_at,
|
|
17
|
+
COUNT(e.id) as evidence_count
|
|
18
|
+
FROM transient_pattern_suggestions s
|
|
19
|
+
LEFT JOIN pattern_match_evidence e ON e.suggestion_id = s.id
|
|
20
|
+
WHERE s.status = 'shadow'
|
|
21
|
+
AND s.match_count >= 3
|
|
22
|
+
AND s.created_at < datetime('now', '-3 days')
|
|
23
|
+
GROUP BY s.id
|
|
24
|
+
ORDER BY s.match_count DESC
|
|
25
|
+
LIMIT 20`
|
|
26
|
+
)
|
|
27
|
+
.all();
|
|
28
|
+
|
|
29
|
+
return new Response(
|
|
30
|
+
JSON.stringify({ patterns: patterns.results ?? [], count: patterns.results?.length ?? 0 }),
|
|
31
|
+
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60' } }
|
|
32
|
+
);
|
|
33
|
+
} catch {
|
|
34
|
+
return new Response(JSON.stringify({ patterns: [], count: 0 }), {
|
|
35
|
+
status: 500,
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const GET: APIRoute = async ({ locals }) => {
|
|
4
|
+
const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
|
|
5
|
+
|
|
6
|
+
if (!db) {
|
|
7
|
+
return new Response(JSON.stringify({ byStatus: {}, total: 0 }), {
|
|
8
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const counts = await db
|
|
14
|
+
.prepare(
|
|
15
|
+
`SELECT status, COUNT(*) as count
|
|
16
|
+
FROM transient_pattern_suggestions
|
|
17
|
+
GROUP BY status
|
|
18
|
+
LIMIT 10`
|
|
19
|
+
)
|
|
20
|
+
.all<{ status: string; count: number }>();
|
|
21
|
+
|
|
22
|
+
const byStatus: Record<string, number> = {};
|
|
23
|
+
let total = 0;
|
|
24
|
+
for (const row of counts.results ?? []) {
|
|
25
|
+
byStatus[row.status] = row.count;
|
|
26
|
+
total += row.count;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return new Response(
|
|
30
|
+
JSON.stringify({ byStatus, total }),
|
|
31
|
+
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60' } }
|
|
32
|
+
);
|
|
33
|
+
} catch {
|
|
34
|
+
return new Response(JSON.stringify({ byStatus: {}, total: 0 }), {
|
|
35
|
+
status: 500,
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
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({ suggestions: [], total: 0 }), {
|
|
8
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const status = url.searchParams.get('status') ?? 'pending';
|
|
13
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50'), 100);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const suggestions = await db
|
|
17
|
+
.prepare(
|
|
18
|
+
`SELECT id, pattern_type, pattern_value, error_type, priority, status,
|
|
19
|
+
match_count, source, created_at, reviewed_at, reviewer_notes, is_protected
|
|
20
|
+
FROM transient_pattern_suggestions
|
|
21
|
+
WHERE status = ?
|
|
22
|
+
ORDER BY match_count DESC, created_at DESC
|
|
23
|
+
LIMIT ?`
|
|
24
|
+
)
|
|
25
|
+
.bind(status, limit)
|
|
26
|
+
.all();
|
|
27
|
+
|
|
28
|
+
const total = await db
|
|
29
|
+
.prepare(`SELECT COUNT(*) as count FROM transient_pattern_suggestions WHERE status = ? LIMIT 1`)
|
|
30
|
+
.bind(status)
|
|
31
|
+
.first<{ count: number }>();
|
|
32
|
+
|
|
33
|
+
return new Response(
|
|
34
|
+
JSON.stringify({ suggestions: suggestions.results ?? [], total: total?.count ?? 0 }),
|
|
35
|
+
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=30' } }
|
|
36
|
+
);
|
|
37
|
+
} catch {
|
|
38
|
+
return new Response(JSON.stringify({ suggestions: [], total: 0 }), {
|
|
39
|
+
status: 500,
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
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({ reports: [] }), {
|
|
8
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const project = url.searchParams.get('project');
|
|
13
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '10'), 50);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const query = project
|
|
17
|
+
? `SELECT id, project, scan_type, ai_judge_score, sdk_score, observability_score,
|
|
18
|
+
cost_protection_score, security_score, scan_date, focused_dimensions
|
|
19
|
+
FROM audit_results
|
|
20
|
+
WHERE project = ?
|
|
21
|
+
ORDER BY scan_date DESC
|
|
22
|
+
LIMIT ?`
|
|
23
|
+
: `SELECT id, project, scan_type, ai_judge_score, sdk_score, observability_score,
|
|
24
|
+
cost_protection_score, security_score, scan_date, focused_dimensions
|
|
25
|
+
FROM audit_results
|
|
26
|
+
ORDER BY scan_date DESC
|
|
27
|
+
LIMIT ?`;
|
|
28
|
+
|
|
29
|
+
const stmt = project
|
|
30
|
+
? db.prepare(query).bind(project, limit)
|
|
31
|
+
: db.prepare(query).bind(limit);
|
|
32
|
+
|
|
33
|
+
const reports = await stmt.all();
|
|
34
|
+
|
|
35
|
+
return new Response(
|
|
36
|
+
JSON.stringify({ reports: reports.results ?? [] }),
|
|
37
|
+
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=120' } }
|
|
38
|
+
);
|
|
39
|
+
} catch {
|
|
40
|
+
return new Response(JSON.stringify({ reports: [] }), {
|
|
41
|
+
status: 500,
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
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({ daily: [], monthly: [] }), {
|
|
8
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const days = Math.min(parseInt(url.searchParams.get('days') ?? '30'), 90);
|
|
13
|
+
const startDate = new Date();
|
|
14
|
+
startDate.setDate(startDate.getDate() - days);
|
|
15
|
+
const startStr = startDate.toISOString().slice(0, 10);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const [daily, monthly] = await Promise.all([
|
|
19
|
+
db
|
|
20
|
+
.prepare(
|
|
21
|
+
`SELECT snapshot_date, d1_reads, d1_writes, kv_reads, kv_writes,
|
|
22
|
+
r2_reads, r2_writes, worker_requests, total_cost_usd
|
|
23
|
+
FROM daily_usage_rollups
|
|
24
|
+
WHERE project = 'all' AND snapshot_date >= ?
|
|
25
|
+
ORDER BY snapshot_date ASC
|
|
26
|
+
LIMIT 90`
|
|
27
|
+
)
|
|
28
|
+
.bind(startStr)
|
|
29
|
+
.all(),
|
|
30
|
+
db
|
|
31
|
+
.prepare(
|
|
32
|
+
`SELECT snapshot_month, d1_reads, d1_writes, kv_reads, kv_writes,
|
|
33
|
+
r2_reads, r2_writes, worker_requests, total_cost_usd
|
|
34
|
+
FROM monthly_usage_rollups
|
|
35
|
+
WHERE project = 'all'
|
|
36
|
+
ORDER BY snapshot_month DESC
|
|
37
|
+
LIMIT 12`
|
|
38
|
+
)
|
|
39
|
+
.all(),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
return new Response(
|
|
43
|
+
JSON.stringify({ daily: daily.results ?? [], monthly: monthly.results ?? [] }),
|
|
44
|
+
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' } }
|
|
45
|
+
);
|
|
46
|
+
} catch {
|
|
47
|
+
return new Response(JSON.stringify({ daily: [], monthly: [] }), {
|
|
48
|
+
status: 500,
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const POST: APIRoute = async ({ locals }) => {
|
|
4
|
+
const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
|
|
5
|
+
|
|
6
|
+
if (!db) {
|
|
7
|
+
return new Response(JSON.stringify({ error: 'Database not available' }), {
|
|
8
|
+
status: 503,
|
|
9
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
await db
|
|
15
|
+
.prepare(`INSERT INTO search_index(search_index) VALUES('rebuild')`)
|
|
16
|
+
.run();
|
|
17
|
+
|
|
18
|
+
return new Response(
|
|
19
|
+
JSON.stringify({ ok: true, message: 'FTS5 reindex started' }),
|
|
20
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
21
|
+
);
|
|
22
|
+
} catch {
|
|
23
|
+
return new Response(JSON.stringify({ error: 'Reindex failed' }), {
|
|
24
|
+
status: 500,
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const GET: APIRoute = async ({ locals }) => {
|
|
4
|
+
const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
|
|
5
|
+
|
|
6
|
+
if (!db) {
|
|
7
|
+
return new Response(JSON.stringify({ indexed: 0, lastUpdated: null }), {
|
|
8
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const count = await db
|
|
14
|
+
.prepare(`SELECT COUNT(*) as count FROM search_index LIMIT 1`)
|
|
15
|
+
.first<{ count: number }>();
|
|
16
|
+
|
|
17
|
+
return new Response(
|
|
18
|
+
JSON.stringify({ indexed: count?.count ?? 0 }),
|
|
19
|
+
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60' } }
|
|
20
|
+
);
|
|
21
|
+
} catch {
|
|
22
|
+
return new Response(JSON.stringify({ indexed: 0 }), {
|
|
23
|
+
status: 500,
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const GET: APIRoute = async ({ locals }) => {
|
|
4
|
+
const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
|
|
5
|
+
|
|
6
|
+
if (!db) {
|
|
7
|
+
return new Response(JSON.stringify({ settings: {} }), {
|
|
8
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const rows = await db
|
|
14
|
+
.prepare(
|
|
15
|
+
`SELECT key, value, updated_at
|
|
16
|
+
FROM platform_settings
|
|
17
|
+
ORDER BY key ASC
|
|
18
|
+
LIMIT 200`
|
|
19
|
+
)
|
|
20
|
+
.all<{ key: string; value: string; updated_at: string }>();
|
|
21
|
+
|
|
22
|
+
const settings: Record<string, { value: string; updatedAt: string }> = {};
|
|
23
|
+
for (const row of rows.results ?? []) {
|
|
24
|
+
settings[row.key] = { value: row.value, updatedAt: row.updated_at };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return new Response(
|
|
28
|
+
JSON.stringify({ settings }),
|
|
29
|
+
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=30' } }
|
|
30
|
+
);
|
|
31
|
+
} catch {
|
|
32
|
+
return new Response(JSON.stringify({ settings: {} }), {
|
|
33
|
+
status: 500,
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const POST: APIRoute = async ({ request, locals }) => {
|
|
4
|
+
const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
|
|
5
|
+
|
|
6
|
+
if (!db) {
|
|
7
|
+
return new Response(JSON.stringify({ error: 'Database not available' }), {
|
|
8
|
+
status: 503,
|
|
9
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const body = (await request.json()) as { key?: string; value?: string };
|
|
15
|
+
if (!body.key || body.value === undefined) {
|
|
16
|
+
return new Response(JSON.stringify({ error: 'key and value are required' }), {
|
|
17
|
+
status: 400,
|
|
18
|
+
headers: { 'Content-Type': 'application/json' },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
await db
|
|
23
|
+
.prepare(
|
|
24
|
+
`INSERT INTO platform_settings (key, value, updated_at)
|
|
25
|
+
VALUES (?, ?, datetime('now'))
|
|
26
|
+
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
|
27
|
+
)
|
|
28
|
+
.bind(body.key, body.value)
|
|
29
|
+
.run();
|
|
30
|
+
|
|
31
|
+
return new Response(
|
|
32
|
+
JSON.stringify({ ok: true, key: body.key }),
|
|
33
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
34
|
+
);
|
|
35
|
+
} catch {
|
|
36
|
+
return new Response(JSON.stringify({ error: 'Update failed' }), {
|
|
37
|
+
status: 500,
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
|
|
3
|
+
export const GET: APIRoute = async ({ locals }) => {
|
|
4
|
+
const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
|
|
5
|
+
|
|
6
|
+
if (!db) {
|
|
7
|
+
return new Response(JSON.stringify({ nodes: [], edges: [] }), {
|
|
8
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const [workers, bindings] = await Promise.all([
|
|
14
|
+
db
|
|
15
|
+
.prepare(
|
|
16
|
+
`SELECT script_name, project, worker_type, last_seen_at
|
|
17
|
+
FROM resource_project_mapping
|
|
18
|
+
WHERE resource_type = 'worker'
|
|
19
|
+
ORDER BY script_name ASC
|
|
20
|
+
LIMIT 100`
|
|
21
|
+
)
|
|
22
|
+
.all(),
|
|
23
|
+
db
|
|
24
|
+
.prepare(
|
|
25
|
+
`SELECT source_worker, target_worker, binding_type
|
|
26
|
+
FROM service_bindings
|
|
27
|
+
ORDER BY source_worker ASC
|
|
28
|
+
LIMIT 200`
|
|
29
|
+
)
|
|
30
|
+
.all(),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const nodes = (workers.results ?? []).map((w: Record<string, unknown>) => ({
|
|
34
|
+
id: w.script_name as string,
|
|
35
|
+
project: w.project as string,
|
|
36
|
+
type: w.worker_type as string,
|
|
37
|
+
lastSeen: w.last_seen_at as string,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const edges = (bindings.results ?? []).map((b: Record<string, unknown>) => ({
|
|
41
|
+
from: b.source_worker as string,
|
|
42
|
+
to: b.target_worker as string,
|
|
43
|
+
type: b.binding_type as string,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
return new Response(
|
|
47
|
+
JSON.stringify({ nodes, edges }),
|
|
48
|
+
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' } }
|
|
49
|
+
);
|
|
50
|
+
} catch {
|
|
51
|
+
return new Response(JSON.stringify({ nodes: [], edges: [] }), {
|
|
52
|
+
status: 500,
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Universal Backfill Script (Full Tier)
|
|
4
|
+
*
|
|
5
|
+
* Orchestrates the hourly → daily → monthly cascade with progress reporting.
|
|
6
|
+
* Runs each step in sequence, passing date ranges through the pipeline.
|
|
7
|
+
*
|
|
8
|
+
* Prerequisites:
|
|
9
|
+
* CLOUDFLARE_API_TOKEN — API token with Analytics:Read + D1:Write permissions
|
|
10
|
+
* CLOUDFLARE_ACCOUNT_ID — Your Cloudflare account ID
|
|
11
|
+
* D1_DATABASE_ID — Your platform-metrics D1 database ID
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* npx tsx scripts/ops/universal-backfill.ts
|
|
15
|
+
* npx tsx scripts/ops/universal-backfill.ts --dry-run
|
|
16
|
+
* npx tsx scripts/ops/universal-backfill.ts --start 2026-02-01 --end 2026-02-28
|
|
17
|
+
* npx tsx scripts/ops/universal-backfill.ts --skip-hourly
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execSync } from 'node:child_process';
|
|
21
|
+
import { resolve } from 'node:path';
|
|
22
|
+
|
|
23
|
+
const SCRIPTS_DIR = resolve(import.meta.dirname);
|
|
24
|
+
|
|
25
|
+
interface Args {
|
|
26
|
+
start?: string;
|
|
27
|
+
end?: string;
|
|
28
|
+
dryRun: boolean;
|
|
29
|
+
skipHourly: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseArgs(): Args {
|
|
33
|
+
const args = process.argv.slice(2);
|
|
34
|
+
const result: Args = { dryRun: false, skipHourly: false };
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < args.length; i++) {
|
|
37
|
+
if (args[i] === '--start' && args[i + 1]) result.start = args[++i];
|
|
38
|
+
else if (args[i] === '--end' && args[i + 1]) result.end = args[++i];
|
|
39
|
+
else if (args[i] === '--dry-run') result.dryRun = true;
|
|
40
|
+
else if (args[i] === '--skip-hourly') result.skipHourly = true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!result.start) {
|
|
44
|
+
const d = new Date();
|
|
45
|
+
d.setDate(d.getDate() - 7);
|
|
46
|
+
result.start = d.toISOString().slice(0, 10);
|
|
47
|
+
}
|
|
48
|
+
if (!result.end) {
|
|
49
|
+
result.end = new Date().toISOString().slice(0, 10);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runStep(name: string, script: string, extraArgs: string[]) {
|
|
56
|
+
console.log(`\n${'='.repeat(60)}`);
|
|
57
|
+
console.log(`Step: ${name}`);
|
|
58
|
+
console.log('='.repeat(60));
|
|
59
|
+
|
|
60
|
+
const cmd = `npx tsx ${script} ${extraArgs.join(' ')}`;
|
|
61
|
+
console.log(`Running: ${cmd}\n`);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
execSync(cmd, { stdio: 'inherit', env: process.env });
|
|
65
|
+
console.log(`\n${name}: COMPLETE`);
|
|
66
|
+
return true;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error(`\n${name}: FAILED`);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function main() {
|
|
74
|
+
const args = parseArgs();
|
|
75
|
+
const dateArgs = [];
|
|
76
|
+
if (args.start) dateArgs.push('--start', args.start);
|
|
77
|
+
if (args.end) dateArgs.push('--end', args.end);
|
|
78
|
+
if (args.dryRun) dateArgs.push('--dry-run');
|
|
79
|
+
|
|
80
|
+
console.log('Universal Backfill Pipeline');
|
|
81
|
+
console.log(`Range: ${args.start} → ${args.end}`);
|
|
82
|
+
console.log(`Dry run: ${args.dryRun}`);
|
|
83
|
+
console.log(`Skip hourly: ${args.skipHourly}`);
|
|
84
|
+
|
|
85
|
+
const startTime = Date.now();
|
|
86
|
+
const results: Array<{ step: string; success: boolean }> = [];
|
|
87
|
+
|
|
88
|
+
// Step 1: Hourly backfill (optional — requires GraphQL API, rate-limited)
|
|
89
|
+
if (!args.skipHourly) {
|
|
90
|
+
const success = runStep(
|
|
91
|
+
'1/3: Hourly Backfill',
|
|
92
|
+
resolve(SCRIPTS_DIR, 'backfill-cloudflare-hourly.ts'),
|
|
93
|
+
dateArgs
|
|
94
|
+
);
|
|
95
|
+
results.push({ step: 'Hourly', success });
|
|
96
|
+
if (!success && !args.dryRun) {
|
|
97
|
+
console.warn('\nHourly backfill failed — continuing with daily rollups using existing hourly data.');
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
console.log('\nSkipping hourly backfill (--skip-hourly)');
|
|
101
|
+
results.push({ step: 'Hourly', success: true });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Step 2: Daily rollups
|
|
105
|
+
const dailySuccess = runStep(
|
|
106
|
+
'2/3: Daily Rollups',
|
|
107
|
+
resolve(SCRIPTS_DIR, 'backfill-cloudflare-daily.ts'),
|
|
108
|
+
dateArgs
|
|
109
|
+
);
|
|
110
|
+
results.push({ step: 'Daily', success: dailySuccess });
|
|
111
|
+
|
|
112
|
+
// Step 3: Monthly rollups
|
|
113
|
+
const monthStart = args.start?.slice(0, 7) ?? '';
|
|
114
|
+
const monthEnd = args.end?.slice(0, 7) ?? '';
|
|
115
|
+
const monthlyArgs = [];
|
|
116
|
+
if (monthStart) monthlyArgs.push('--start', monthStart);
|
|
117
|
+
if (monthEnd) monthlyArgs.push('--end', monthEnd);
|
|
118
|
+
if (args.dryRun) monthlyArgs.push('--dry-run');
|
|
119
|
+
|
|
120
|
+
const monthlySuccess = runStep(
|
|
121
|
+
'3/3: Monthly Rollups',
|
|
122
|
+
resolve(SCRIPTS_DIR, 'backfill-monthly-rollups.ts'),
|
|
123
|
+
monthlyArgs
|
|
124
|
+
);
|
|
125
|
+
results.push({ step: 'Monthly', success: monthlySuccess });
|
|
126
|
+
|
|
127
|
+
// Summary
|
|
128
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
129
|
+
console.log(`\n${'='.repeat(60)}`);
|
|
130
|
+
console.log('Pipeline Summary');
|
|
131
|
+
console.log('='.repeat(60));
|
|
132
|
+
for (const r of results) {
|
|
133
|
+
console.log(` ${r.success ? 'PASS' : 'FAIL'} ${r.step}`);
|
|
134
|
+
}
|
|
135
|
+
console.log(`\nTotal time: ${elapsed}s`);
|
|
136
|
+
|
|
137
|
+
const failed = results.filter((r) => !r.success);
|
|
138
|
+
if (failed.length > 0) {
|
|
139
|
+
console.error(`\n${failed.length} step(s) failed.`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
main().catch((err) => {
|
|
145
|
+
console.error('Fatal error:', err);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Contract Schema Validation
|
|
2
|
+
#
|
|
3
|
+
# Validates JSON schemas and TypeScript types match expected contracts.
|
|
4
|
+
# Runs schema validation against fixture data.
|
|
5
|
+
#
|
|
6
|
+
# Checks performed:
|
|
7
|
+
# - JSON schema validation (envelope, error_report)
|
|
8
|
+
# - TypeScript type compilation
|
|
9
|
+
# - Contract fixture validation
|
|
10
|
+
|
|
11
|
+
name: Contract Check
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
push:
|
|
15
|
+
branches: [main]
|
|
16
|
+
paths:
|
|
17
|
+
- 'contracts/**'
|
|
18
|
+
- 'tests/contract/**'
|
|
19
|
+
pull_request:
|
|
20
|
+
branches: [main]
|
|
21
|
+
paths:
|
|
22
|
+
- 'contracts/**'
|
|
23
|
+
- 'tests/contract/**'
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
validate:
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
|
|
31
|
+
- uses: actions/setup-node@v4
|
|
32
|
+
with:
|
|
33
|
+
node-version: '22'
|
|
34
|
+
cache: 'npm'
|
|
35
|
+
|
|
36
|
+
- run: npm ci
|
|
37
|
+
|
|
38
|
+
- name: Validate schemas
|
|
39
|
+
run: npm run validate:schemas
|
|
40
|
+
|
|
41
|
+
- name: Run contract tests
|
|
42
|
+
run: npx vitest run tests/contract/
|