@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,67 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { LoadingSkeleton } from '../ui/LoadingSkeleton';
3
+
4
+ interface Allowance {
5
+ resource: string;
6
+ allowance: number;
7
+ used: number;
8
+ pct: number;
9
+ }
10
+
11
+ function formatNumber(n: number): string {
12
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
13
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
14
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
15
+ return String(n);
16
+ }
17
+
18
+ function pctColor(pct: number): string {
19
+ if (pct >= 90) return 'bg-red-500';
20
+ if (pct >= 75) return 'bg-orange-500';
21
+ if (pct >= 50) return 'bg-yellow-500';
22
+ return 'bg-green-500';
23
+ }
24
+
25
+ export function PlanAllowanceDashboard() {
26
+ const [allowances, setAllowances] = useState<Allowance[]>([]);
27
+ const [loading, setLoading] = useState(true);
28
+
29
+ useEffect(() => {
30
+ fetch('/api/usage/allowances')
31
+ .then(res => res.json())
32
+ .then((data: { allowances: Allowance[] }) => { setAllowances(data.allowances); setLoading(false); })
33
+ .catch(() => setLoading(false));
34
+ }, []);
35
+
36
+ if (loading) return <LoadingSkeleton lines={4} />;
37
+
38
+ if (allowances.length === 0) {
39
+ return <p className="text-sm text-gray-500 dark:text-gray-400">No allowance data available.</p>;
40
+ }
41
+
42
+ return (
43
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
44
+ <h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
45
+ Plan Allowances (MTD)
46
+ </h3>
47
+ <div className="space-y-3">
48
+ {allowances.map(a => (
49
+ <div key={a.resource}>
50
+ <div className="flex items-center justify-between text-sm mb-1">
51
+ <span className="text-gray-700 dark:text-gray-300">{a.resource.replace(/_/g, ' ')}</span>
52
+ <span className="text-gray-500 dark:text-gray-400">
53
+ {formatNumber(a.used)} / {formatNumber(a.allowance)} ({a.pct}%)
54
+ </span>
55
+ </div>
56
+ <div className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
57
+ <div
58
+ className={`h-full rounded-full transition-all ${pctColor(a.pct)}`}
59
+ style={{ width: `${Math.min(a.pct, 100)}%` }}
60
+ />
61
+ </div>
62
+ </div>
63
+ ))}
64
+ </div>
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,55 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { LoadingSkeleton } from '../ui/LoadingSkeleton';
3
+ import { EmptyState } from '../ui/EmptyState';
4
+
5
+ interface ProjectCost {
6
+ project: string;
7
+ total_cost_usd: number;
8
+ d1_writes: number;
9
+ worker_requests: number;
10
+ }
11
+
12
+ export function ProjectCostBreakdown() {
13
+ const [projects, setProjects] = useState<ProjectCost[]>([]);
14
+ const [loading, setLoading] = useState(true);
15
+
16
+ useEffect(() => {
17
+ fetch('/api/usage/projects')
18
+ .then((r) => r.json())
19
+ .then((data: { projects: ProjectCost[] }) => {
20
+ setProjects(data.projects ?? []);
21
+ setLoading(false);
22
+ })
23
+ .catch(() => setLoading(false));
24
+ }, []);
25
+
26
+ if (loading) return <LoadingSkeleton lines={4} />;
27
+ if (projects.length === 0) return <EmptyState title="No project data" description="Project cost data will appear after usage collection runs." />;
28
+
29
+ const maxCost = Math.max(...projects.map((p) => p.total_cost_usd), 0.01);
30
+
31
+ return (
32
+ <div className="space-y-3">
33
+ {projects.map((p) => (
34
+ <div key={p.project} className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-3">
35
+ <div className="flex items-center justify-between mb-1">
36
+ <span className="text-sm font-medium text-gray-900 dark:text-white">{p.project}</span>
37
+ <span className="text-sm font-mono text-gray-700 dark:text-gray-300">
38
+ ${p.total_cost_usd.toFixed(2)}
39
+ </span>
40
+ </div>
41
+ <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
42
+ <div
43
+ className="bg-blue-500 h-1.5 rounded-full transition-all"
44
+ style={{ width: `${Math.min((p.total_cost_usd / maxCost) * 100, 100)}%` }}
45
+ />
46
+ </div>
47
+ <div className="flex gap-4 mt-1 text-xs text-gray-500 dark:text-gray-400">
48
+ <span>D1 writes: {p.d1_writes.toLocaleString()}</span>
49
+ <span>Requests: {p.worker_requests.toLocaleString()}</span>
50
+ </div>
51
+ </div>
52
+ ))}
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,4 @@
1
+ export { HourlyUsageChart } from './HourlyUsageChart';
2
+ export { PlanAllowanceDashboard } from './PlanAllowanceDashboard';
3
+ export { AnomaliesWidget } from './AnomaliesWidget';
4
+ export { ProjectCostBreakdown } from './ProjectCostBreakdown';
@@ -0,0 +1,21 @@
1
+ /** Workers Paid Plan monthly allowances (used to compute net costs). */
2
+ export const CF_PAID_ALLOWANCES: Record<string, number> = {
3
+ d1_reads: 25_000_000_000,
4
+ d1_writes: 50_000_000,
5
+ kv_reads: 10_000_000,
6
+ kv_writes: 1_000_000,
7
+ worker_requests: 10_000_000,
8
+ r2_reads: 10_000_000,
9
+ r2_writes: 1_000_000,
10
+ };
11
+
12
+ /** Per-unit pricing after allowances are exhausted (USD). */
13
+ export const CF_PRICING: Record<string, number> = {
14
+ d1_reads: 0.001 / 4_000_000, // $0.001 per 4M rows
15
+ d1_writes: 1.0 / 1_000_000, // $1.00 per 1M rows
16
+ kv_reads: 0.50 / 1_000_000, // $0.50 per 1M reads
17
+ kv_writes: 5.0 / 1_000_000, // $5.00 per 1M writes
18
+ worker_requests: 0.30 / 1_000_000, // $0.30 per 1M requests
19
+ r2_reads: 0.36 / 1_000_000, // $0.36 per 1M Class B
20
+ r2_writes: 4.50 / 1_000_000, // $4.50 per 1M Class A
21
+ };
@@ -0,0 +1,65 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { CF_PAID_ALLOWANCES, CF_PRICING } from '../../../lib/cloudflare/costs';
3
+
4
+ interface Env {
5
+ PLATFORM_DB?: D1Database;
6
+ }
7
+
8
+ interface CostLine {
9
+ resource: string;
10
+ used: number;
11
+ allowance: number;
12
+ overage: number;
13
+ cost: number;
14
+ }
15
+
16
+ export const GET: APIRoute = async ({ locals }) => {
17
+ const env = locals.runtime?.env as Env | undefined;
18
+ const db = env?.PLATFORM_DB;
19
+
20
+ const overview = {
21
+ lines: [] as CostLine[],
22
+ totalCost: 0,
23
+ period: 'mtd',
24
+ billingStart: '',
25
+ };
26
+
27
+ const now = new Date();
28
+ overview.billingStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
29
+
30
+ if (db) {
31
+ try {
32
+ const result = await db
33
+ .prepare(
34
+ `SELECT SUM(d1_reads) as d1_reads, SUM(d1_writes) as d1_writes,
35
+ SUM(kv_reads) as kv_reads, SUM(kv_writes) as kv_writes,
36
+ SUM(worker_requests) as worker_requests,
37
+ SUM(r2_reads) as r2_reads, SUM(r2_writes) as r2_writes
38
+ FROM daily_usage_rollups
39
+ WHERE project = 'all' AND snapshot_date >= ?
40
+ LIMIT 1`
41
+ )
42
+ .bind(overview.billingStart)
43
+ .first<Record<string, number>>();
44
+
45
+ if (result) {
46
+ for (const [resource, allowance] of Object.entries(CF_PAID_ALLOWANCES)) {
47
+ const used = result[resource] ?? 0;
48
+ const overage = Math.max(0, used - allowance);
49
+ const unitPrice = CF_PRICING[resource] ?? 0;
50
+ const cost = Math.round(overage * unitPrice * 100) / 100;
51
+
52
+ overview.lines.push({ resource, used, allowance, overage, cost });
53
+ overview.totalCost += cost;
54
+ }
55
+ overview.totalCost = Math.round(overview.totalCost * 100) / 100;
56
+ }
57
+ } catch {
58
+ // Table may not exist
59
+ }
60
+ }
61
+
62
+ return new Response(JSON.stringify(overview), {
63
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' },
64
+ });
65
+ };
@@ -0,0 +1,47 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ interface Env {
4
+ PLATFORM_DB?: D1Database;
5
+ }
6
+
7
+ export const GET: APIRoute = async ({ locals }) => {
8
+ const env = locals.runtime?.env as Env | undefined;
9
+ const db = env?.PLATFORM_DB;
10
+
11
+ const providers: Array<{
12
+ provider: string;
13
+ mtd_cost: number;
14
+ latest_date: string;
15
+ }> = [];
16
+
17
+ if (db) {
18
+ try {
19
+ const monthStart = new Date();
20
+ monthStart.setDate(1);
21
+ const cutoff = monthStart.toISOString().slice(0, 10);
22
+
23
+ const result = await db
24
+ .prepare(
25
+ `SELECT provider,
26
+ SUM(cost_usd) as mtd_cost,
27
+ MAX(snapshot_date) as latest_date
28
+ FROM third_party_usage
29
+ WHERE snapshot_date >= ?
30
+ GROUP BY provider
31
+ ORDER BY mtd_cost DESC
32
+ LIMIT 20`
33
+ )
34
+ .bind(cutoff)
35
+ .all();
36
+ if (result.results) {
37
+ providers.push(...(result.results as typeof providers));
38
+ }
39
+ } catch {
40
+ // Table may not exist
41
+ }
42
+ }
43
+
44
+ return new Response(JSON.stringify({ providers }), {
45
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' },
46
+ });
47
+ };
@@ -0,0 +1,55 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ interface Env {
4
+ PLATFORM_DB?: D1Database;
5
+ PLATFORM_CACHE?: KVNamespace;
6
+ }
7
+
8
+ export const GET: APIRoute = async ({ locals }) => {
9
+ const env = locals.runtime?.env as Env | undefined;
10
+ const db = env?.PLATFORM_DB;
11
+ const kv = env?.PLATFORM_CACHE;
12
+
13
+ let services: Array<{
14
+ name: string;
15
+ type: string;
16
+ project: string;
17
+ status: string;
18
+ }> = [];
19
+
20
+ // Try KV first
21
+ if (kv) {
22
+ try {
23
+ const registry = await kv.get('SERVICE_REGISTRY', 'json');
24
+ if (registry && Array.isArray(registry)) {
25
+ services = registry as typeof services;
26
+ }
27
+ } catch {
28
+ // KV may not be available
29
+ }
30
+ }
31
+
32
+ // Fall back to D1
33
+ if (services.length === 0 && db) {
34
+ try {
35
+ const result = await db
36
+ .prepare(
37
+ `SELECT resource_name as name, resource_type as type,
38
+ project_id as project, 'deployed' as status
39
+ FROM resource_project_mapping
40
+ ORDER BY project_id, resource_type
41
+ LIMIT 200`
42
+ )
43
+ .all();
44
+ if (result.results) {
45
+ services = result.results as typeof services;
46
+ }
47
+ } catch {
48
+ // Table may not exist
49
+ }
50
+ }
51
+
52
+ return new Response(JSON.stringify({ services }), {
53
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=120' },
54
+ });
55
+ };
@@ -0,0 +1,99 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ interface Env {
4
+ PLATFORM_DB?: D1Database;
5
+ PLATFORM_CACHE?: KVNamespace;
6
+ }
7
+
8
+ export const GET: APIRoute = async ({ locals }) => {
9
+ const env = locals.runtime?.env as Env | undefined;
10
+ const db = env?.PLATFORM_DB;
11
+ const kv = env?.PLATFORM_CACHE;
12
+
13
+ const stats = {
14
+ services: {
15
+ total: 0,
16
+ byStatus: { deployed: 0, development: 0, deprecated: 0, paused: 0 },
17
+ byType: {} as Record<string, number>,
18
+ byProject: {} as Record<string, number>,
19
+ },
20
+ alerts: {
21
+ total: 0,
22
+ unacknowledged: 0,
23
+ critical: 0,
24
+ },
25
+ };
26
+
27
+ // Try KV first (faster, populated by sync-config)
28
+ if (kv) {
29
+ try {
30
+ const services = await kv.get('SERVICE_REGISTRY', 'json');
31
+ if (services && Array.isArray(services)) {
32
+ stats.services.total = services.length;
33
+ for (const s of services as Array<{ status: string; type: string; project: string }>) {
34
+ const status = s.status as keyof typeof stats.services.byStatus;
35
+ if (status in stats.services.byStatus) {
36
+ stats.services.byStatus[status]++;
37
+ }
38
+ stats.services.byType[s.type] = (stats.services.byType[s.type] || 0) + 1;
39
+ stats.services.byProject[s.project] = (stats.services.byProject[s.project] || 0) + 1;
40
+ }
41
+ }
42
+ } catch {
43
+ // KV may not be available
44
+ }
45
+ }
46
+
47
+ // Fall back to D1 if KV had nothing
48
+ if (stats.services.total === 0 && db) {
49
+ try {
50
+ const rows = await db
51
+ .prepare(
52
+ `SELECT resource_type, project_id, COUNT(*) as cnt
53
+ FROM resource_project_mapping
54
+ GROUP BY resource_type, project_id
55
+ LIMIT 200`
56
+ )
57
+ .all<{ resource_type: string; project_id: string; cnt: number }>();
58
+ if (rows.results) {
59
+ for (const row of rows.results) {
60
+ stats.services.total += row.cnt;
61
+ stats.services.byStatus.deployed += row.cnt;
62
+ stats.services.byType[row.resource_type] = (stats.services.byType[row.resource_type] || 0) + row.cnt;
63
+ stats.services.byProject[row.project_id] = (stats.services.byProject[row.project_id] || 0) + row.cnt;
64
+ }
65
+ }
66
+ } catch {
67
+ // Table may not exist
68
+ }
69
+ }
70
+
71
+ // Alert stats from notifications
72
+ if (db) {
73
+ try {
74
+ const alertResult = await db
75
+ .prepare(
76
+ `SELECT
77
+ COUNT(*) as total,
78
+ SUM(CASE WHEN category IN ('error', 'warning') THEN 1 ELSE 0 END) as unacknowledged,
79
+ SUM(CASE WHEN priority IN ('critical', 'high') AND category = 'error' THEN 1 ELSE 0 END) as critical
80
+ FROM notifications
81
+ WHERE created_at > unixepoch() - 86400 * 7
82
+ AND (expires_at IS NULL OR expires_at > unixepoch())
83
+ LIMIT 1`
84
+ )
85
+ .first<{ total: number; unacknowledged: number; critical: number }>();
86
+ if (alertResult) {
87
+ stats.alerts.total = alertResult.total || 0;
88
+ stats.alerts.unacknowledged = alertResult.unacknowledged || 0;
89
+ stats.alerts.critical = alertResult.critical || 0;
90
+ }
91
+ } catch {
92
+ // Table may not exist
93
+ }
94
+ }
95
+
96
+ return new Response(JSON.stringify(stats), {
97
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=120' },
98
+ });
99
+ };
@@ -0,0 +1,56 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { CF_PAID_ALLOWANCES } from '../../../lib/cloudflare/costs';
3
+
4
+ interface Env {
5
+ PLATFORM_DB?: D1Database;
6
+ }
7
+
8
+ export const GET: APIRoute = async ({ locals }) => {
9
+ const env = locals.runtime?.env as Env | undefined;
10
+ const db = env?.PLATFORM_DB;
11
+
12
+ const allowances: Array<{
13
+ resource: string;
14
+ allowance: number;
15
+ used: number;
16
+ pct: number;
17
+ }> = [];
18
+
19
+ if (db) {
20
+ try {
21
+ const monthStart = new Date();
22
+ monthStart.setDate(1);
23
+ const cutoff = monthStart.toISOString().slice(0, 10);
24
+
25
+ const result = await db
26
+ .prepare(
27
+ `SELECT SUM(d1_reads) as d1_reads, SUM(d1_writes) as d1_writes,
28
+ SUM(kv_reads) as kv_reads, SUM(kv_writes) as kv_writes,
29
+ SUM(worker_requests) as worker_requests
30
+ FROM daily_usage_rollups
31
+ WHERE project = 'all' AND snapshot_date >= ?
32
+ LIMIT 1`
33
+ )
34
+ .bind(cutoff)
35
+ .first<Record<string, number>>();
36
+
37
+ if (result) {
38
+ for (const [key, limit] of Object.entries(CF_PAID_ALLOWANCES)) {
39
+ const used = result[key] ?? 0;
40
+ allowances.push({
41
+ resource: key,
42
+ allowance: limit,
43
+ used,
44
+ pct: limit > 0 ? Math.round((used / limit) * 100) : 0,
45
+ });
46
+ }
47
+ }
48
+ } catch {
49
+ // Table may not exist
50
+ }
51
+ }
52
+
53
+ return new Response(JSON.stringify({ allowances }), {
54
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' },
55
+ });
56
+ };
@@ -0,0 +1,45 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ interface Env {
4
+ PLATFORM_DB?: D1Database;
5
+ }
6
+
7
+ export const GET: APIRoute = async ({ locals }) => {
8
+ const env = locals.runtime?.env as Env | undefined;
9
+ const db = env?.PLATFORM_DB;
10
+
11
+ const anomalies: Array<{
12
+ id: number;
13
+ project: string;
14
+ metric: string;
15
+ current_value: number;
16
+ baseline_value: number;
17
+ deviation_pct: number;
18
+ detected_at: string;
19
+ status: string;
20
+ }> = [];
21
+
22
+ if (db) {
23
+ try {
24
+ const result = await db
25
+ .prepare(
26
+ `SELECT id, project, metric, current_value, baseline_value,
27
+ deviation_pct, detected_at, status
28
+ FROM usage_anomalies
29
+ WHERE detected_at >= datetime('now', '-7 days')
30
+ ORDER BY detected_at DESC
31
+ LIMIT 20`
32
+ )
33
+ .all();
34
+ if (result.results) {
35
+ anomalies.push(...(result.results as typeof anomalies));
36
+ }
37
+ } catch {
38
+ // Table may not exist
39
+ }
40
+ }
41
+
42
+ return new Response(JSON.stringify({ anomalies }), {
43
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=120' },
44
+ });
45
+ };
@@ -0,0 +1,53 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ interface Env {
4
+ PLATFORM_DB?: D1Database;
5
+ }
6
+
7
+ export const GET: APIRoute = async ({ locals }) => {
8
+ const env = locals.runtime?.env as Env | undefined;
9
+ const db = env?.PLATFORM_DB;
10
+
11
+ const now = new Date();
12
+ const billingStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
13
+ const billingEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10);
14
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
15
+ const daysSoFar = now.getDate();
16
+
17
+ const billing = {
18
+ cycleStart: billingStart,
19
+ cycleEnd: billingEnd,
20
+ daysInMonth,
21
+ daysSoFar,
22
+ currentSpend: 0,
23
+ dailyBurnRate: 0,
24
+ projectedMonthly: 0,
25
+ plan: 'workers_paid' as string,
26
+ };
27
+
28
+ if (db) {
29
+ try {
30
+ const result = await db
31
+ .prepare(
32
+ `SELECT SUM(total_cost_usd) as spend
33
+ FROM daily_usage_rollups
34
+ WHERE project = 'all' AND snapshot_date >= ?
35
+ LIMIT 1`
36
+ )
37
+ .bind(billingStart)
38
+ .first<{ spend: number | null }>();
39
+
40
+ if (result?.spend) {
41
+ billing.currentSpend = Math.round(result.spend * 100) / 100;
42
+ billing.dailyBurnRate = daysSoFar > 0 ? Math.round((billing.currentSpend / daysSoFar) * 100) / 100 : 0;
43
+ billing.projectedMonthly = Math.round(billing.dailyBurnRate * daysInMonth * 100) / 100;
44
+ }
45
+ } catch {
46
+ // Table may not exist
47
+ }
48
+ }
49
+
50
+ return new Response(JSON.stringify(billing), {
51
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' },
52
+ });
53
+ };
@@ -0,0 +1,50 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ interface Env {
4
+ PLATFORM_DB?: D1Database;
5
+ }
6
+
7
+ export const GET: APIRoute = async ({ locals, url }) => {
8
+ const env = locals.runtime?.env as Env | undefined;
9
+ const db = env?.PLATFORM_DB;
10
+ const project = url.searchParams.get('project') ?? 'all';
11
+ const days = Math.min(Number(url.searchParams.get('days') ?? '7'), 30);
12
+
13
+ const rows: Array<{
14
+ snapshot_date: string;
15
+ d1_reads: number;
16
+ d1_writes: number;
17
+ kv_reads: number;
18
+ kv_writes: number;
19
+ r2_reads: number;
20
+ r2_writes: number;
21
+ worker_requests: number;
22
+ total_cost_usd: number;
23
+ }> = [];
24
+
25
+ if (db) {
26
+ try {
27
+ const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
28
+ const result = await db
29
+ .prepare(
30
+ `SELECT snapshot_date, d1_reads, d1_writes, kv_reads, kv_writes,
31
+ r2_reads, r2_writes, worker_requests, total_cost_usd
32
+ FROM daily_usage_rollups
33
+ WHERE project = ? AND snapshot_date >= ?
34
+ ORDER BY snapshot_date ASC
35
+ LIMIT 30`
36
+ )
37
+ .bind(project, cutoff)
38
+ .all();
39
+ if (result.results) {
40
+ rows.push(...(result.results as typeof rows));
41
+ }
42
+ } catch {
43
+ // Table may not exist
44
+ }
45
+ }
46
+
47
+ return new Response(JSON.stringify({ rows, project, days }), {
48
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60' },
49
+ });
50
+ };
@@ -0,0 +1,45 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ interface Env {
4
+ PLATFORM_DB?: D1Database;
5
+ }
6
+
7
+ export const GET: APIRoute = async ({ locals, url }) => {
8
+ const env = locals.runtime?.env as Env | undefined;
9
+ const db = env?.PLATFORM_DB;
10
+ const hours = Math.min(Number(url.searchParams.get('hours') ?? '24'), 168);
11
+
12
+ const snapshots: Array<{
13
+ snapshot_hour: string;
14
+ d1_reads: number;
15
+ d1_writes: number;
16
+ kv_reads: number;
17
+ kv_writes: number;
18
+ total_cost_usd: number;
19
+ }> = [];
20
+
21
+ if (db) {
22
+ try {
23
+ const cutoff = new Date(Date.now() - hours * 3600000).toISOString().slice(0, 19).replace('T', ' ');
24
+ const result = await db
25
+ .prepare(
26
+ `SELECT snapshot_hour, d1_reads, d1_writes, kv_reads, kv_writes, total_cost_usd
27
+ FROM hourly_usage_snapshots
28
+ WHERE project = 'all' AND snapshot_hour >= ?
29
+ ORDER BY snapshot_hour ASC
30
+ LIMIT 168`
31
+ )
32
+ .bind(cutoff)
33
+ .all();
34
+ if (result.results) {
35
+ snapshots.push(...(result.results as typeof snapshots));
36
+ }
37
+ } catch {
38
+ // Table may not exist
39
+ }
40
+ }
41
+
42
+ return new Response(JSON.stringify({ snapshots, hours }), {
43
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60' },
44
+ });
45
+ };