@littlebearapps/platform-admin-sdk 1.4.2 → 1.5.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 (111) hide show
  1. package/dist/templates.d.ts +1 -1
  2. package/dist/templates.js +121 -2
  3. package/package.json +1 -1
  4. package/templates/full/config/audit-targets.yaml +72 -0
  5. package/templates/full/dashboard/src/components/notifications/NotificationBell.tsx +30 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationList.tsx +116 -0
  7. package/templates/full/dashboard/src/components/notifications/index.ts +2 -0
  8. package/templates/full/dashboard/src/components/patterns/PatternStats.tsx +60 -0
  9. package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
  10. package/templates/full/dashboard/src/components/patterns/index.ts +2 -0
  11. package/templates/full/dashboard/src/components/search/SearchModal.tsx +108 -0
  12. package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -0
  13. package/templates/full/dashboard/src/pages/api/notifications/unread-count.ts +31 -0
  14. package/templates/full/dashboard/src/pages/api/patterns/approve.ts +55 -0
  15. package/templates/full/dashboard/src/pages/api/patterns/index.ts +36 -0
  16. package/templates/full/dashboard/src/pages/api/patterns/reject.ts +54 -0
  17. package/templates/full/dashboard/src/pages/api/search/index.ts +74 -0
  18. package/templates/full/dashboard/src/pages/notifications.astro +11 -0
  19. package/templates/full/migrations/008_auditor.sql +99 -0
  20. package/templates/full/migrations/010_pricing_versions.sql +110 -0
  21. package/templates/full/migrations/011_multi_account.sql +51 -0
  22. package/templates/full/scripts/ops/set-kv-pricing.ts +182 -0
  23. package/templates/full/workers/lib/ai-judge-schema.ts +181 -0
  24. package/templates/full/workers/lib/auditor/comprehensive-report.ts +407 -0
  25. package/templates/full/workers/lib/auditor/feature-coverage.ts +348 -0
  26. package/templates/full/workers/lib/auditor/index.ts +9 -0
  27. package/templates/full/workers/lib/auditor/types.ts +167 -0
  28. package/templates/full/workers/platform-auditor.ts +1071 -0
  29. package/templates/full/wrangler.auditor.jsonc.hbs +75 -0
  30. package/templates/shared/.github/workflows/platform-check.yml.hbs +28 -0
  31. package/templates/shared/config/observability.yaml.hbs +276 -0
  32. package/templates/shared/contracts/schemas/envelope.v1.schema.json +64 -0
  33. package/templates/shared/contracts/schemas/error_report.v1.schema.json +65 -0
  34. package/templates/shared/contracts/types/telemetry-envelope.types.ts +139 -0
  35. package/templates/shared/dashboard/astro.config.mjs +21 -0
  36. package/templates/shared/dashboard/package.json.hbs +29 -0
  37. package/templates/shared/dashboard/src/components/Header.astro +29 -0
  38. package/templates/shared/dashboard/src/components/Nav.astro.hbs +57 -0
  39. package/templates/shared/dashboard/src/components/overview/ActivityFeed.tsx +134 -0
  40. package/templates/shared/dashboard/src/components/overview/CostQuadrant.tsx +131 -0
  41. package/templates/shared/dashboard/src/components/overview/ErrorsQuadrant.tsx +113 -0
  42. package/templates/shared/dashboard/src/components/overview/HealthQuadrant.tsx +87 -0
  43. package/templates/shared/dashboard/src/components/overview/MissionControl.tsx +139 -0
  44. package/templates/shared/dashboard/src/components/resources/AllowanceStatus.tsx +44 -0
  45. package/templates/shared/dashboard/src/components/resources/CostCentreOverview.tsx +42 -0
  46. package/templates/shared/dashboard/src/components/resources/ResourceTabs.tsx +69 -0
  47. package/templates/shared/dashboard/src/components/resources/index.ts +3 -0
  48. package/templates/shared/dashboard/src/components/settings/SettingsCard.tsx +21 -0
  49. package/templates/shared/dashboard/src/components/settings/index.ts +1 -0
  50. package/templates/shared/dashboard/src/components/ui/AlertBanner.tsx +39 -0
  51. package/templates/shared/dashboard/src/components/ui/Sparkline.tsx +127 -0
  52. package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
  53. package/templates/shared/dashboard/src/components/ui/index.ts +3 -0
  54. package/templates/shared/dashboard/src/env.d.ts.hbs +34 -0
  55. package/templates/shared/dashboard/src/layouts/DashboardLayout.astro +37 -0
  56. package/templates/shared/dashboard/src/lib/fetch.ts +29 -0
  57. package/templates/shared/dashboard/src/lib/types.ts +72 -0
  58. package/templates/shared/dashboard/src/middleware/auth.ts +100 -0
  59. package/templates/shared/dashboard/src/middleware/index.ts +1 -0
  60. package/templates/shared/dashboard/src/pages/api/overview/summary.ts +311 -0
  61. package/templates/shared/dashboard/src/pages/api/usage/circuit-breakers.ts +44 -0
  62. package/templates/shared/dashboard/src/pages/api/usage/status.ts +42 -0
  63. package/templates/shared/dashboard/src/pages/dashboard.astro +11 -0
  64. package/templates/shared/dashboard/src/pages/index.astro +3 -0
  65. package/templates/shared/dashboard/src/pages/resources.astro +11 -0
  66. package/templates/shared/dashboard/src/pages/settings/index.astro +28 -0
  67. package/templates/shared/dashboard/src/styles/global.css +29 -0
  68. package/templates/shared/dashboard/tailwind.config.mjs +9 -0
  69. package/templates/shared/dashboard/tsconfig.json +9 -0
  70. package/templates/shared/dashboard/wrangler.json.hbs +47 -0
  71. package/templates/shared/package.json.hbs +12 -1
  72. package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -0
  73. package/templates/shared/scripts/ops/discover-graphql-datasets.ts +482 -0
  74. package/templates/shared/scripts/ops/reset-budget-state.ts +279 -0
  75. package/templates/shared/scripts/ops/validate-pipeline.ts +237 -0
  76. package/templates/shared/scripts/ops/verify-account-completeness.ts +236 -0
  77. package/templates/shared/scripts/validate-schemas.js +61 -0
  78. package/templates/shared/workers/lib/usage/collectors/anthropic.ts +114 -0
  79. package/templates/shared/workers/lib/usage/collectors/apify.ts +96 -0
  80. package/templates/shared/workers/lib/usage/collectors/custom-http.ts +151 -0
  81. package/templates/shared/workers/lib/usage/collectors/deepseek.ts +92 -0
  82. package/templates/shared/workers/lib/usage/collectors/gemini.ts +263 -0
  83. package/templates/shared/workers/lib/usage/collectors/github.ts +362 -0
  84. package/templates/shared/workers/lib/usage/collectors/index.ts +31 -15
  85. package/templates/shared/workers/lib/usage/collectors/minimax.ts +106 -0
  86. package/templates/shared/workers/lib/usage/collectors/openai.ts +171 -0
  87. package/templates/shared/workers/lib/usage/collectors/resend.ts +79 -0
  88. package/templates/shared/workers/lib/usage/collectors/stripe.ts +192 -0
  89. package/templates/shared/workers/lib/usage/shared/types.ts +46 -0
  90. package/templates/shared/workers/platform-usage.ts +98 -8
  91. package/templates/standard/dashboard/src/components/errors/ErrorStats.tsx +53 -0
  92. package/templates/standard/dashboard/src/components/errors/ErrorsTable.tsx +133 -0
  93. package/templates/standard/dashboard/src/components/errors/index.ts +2 -0
  94. package/templates/standard/dashboard/src/components/health/DlqStatusCard.tsx +52 -0
  95. package/templates/standard/dashboard/src/components/health/HealthTabs.tsx +86 -0
  96. package/templates/standard/dashboard/src/components/health/index.ts +2 -0
  97. package/templates/standard/dashboard/src/lib/errors.ts +28 -0
  98. package/templates/standard/dashboard/src/pages/api/errors/index.ts +58 -0
  99. package/templates/standard/dashboard/src/pages/api/errors/stats.ts +55 -0
  100. package/templates/standard/dashboard/src/pages/api/health/dlq.ts +43 -0
  101. package/templates/standard/dashboard/src/pages/errors.astro +13 -0
  102. package/templates/standard/dashboard/src/pages/health.astro +11 -0
  103. package/templates/standard/migrations/009_topology_mapper.sql +65 -0
  104. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +37 -3
  105. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +32 -1
  106. package/templates/standard/workers/lib/mapper/attribution-check.ts +339 -0
  107. package/templates/standard/workers/lib/mapper/index.ts +7 -0
  108. package/templates/standard/workers/platform-mapper.ts +482 -0
  109. package/templates/standard/workers/platform-sdk-test-client.ts +125 -0
  110. package/templates/standard/wrangler.mapper.jsonc.hbs +85 -0
  111. package/templates/standard/wrangler.sdk-test-client.jsonc.hbs +62 -0
@@ -0,0 +1,133 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ interface ErrorRow {
4
+ fingerprint: string;
5
+ message: string;
6
+ script_name: string;
7
+ priority: string;
8
+ occurrence_count: number;
9
+ status: string;
10
+ first_seen_at: string;
11
+ last_seen_at: string;
12
+ github_issue_url: string | null;
13
+ }
14
+
15
+ const priorityColours: Record<string, string> = {
16
+ P0: 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300',
17
+ P1: 'bg-orange-100 text-orange-800 dark:bg-orange-900/50 dark:text-orange-300',
18
+ P2: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300',
19
+ P3: 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300',
20
+ P4: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
21
+ };
22
+
23
+ export function ErrorsTable() {
24
+ const [errors, setErrors] = useState<ErrorRow[]>([]);
25
+ const [total, setTotal] = useState(0);
26
+ const [page, setPage] = useState(1);
27
+ const [filterPriority, setFilterPriority] = useState<string>('');
28
+ const [sortField, setSortField] = useState<'priority' | 'occurrence_count' | 'last_seen_at'>('priority');
29
+
30
+ useEffect(() => {
31
+ const params = new URLSearchParams({ page: String(page), limit: '20' });
32
+ if (filterPriority) params.set('priority', filterPriority);
33
+
34
+ fetch(`/api/errors?${params}`)
35
+ .then(res => res.json())
36
+ .then((data: { errors: ErrorRow[]; total: number }) => {
37
+ setErrors(data.errors ?? []);
38
+ setTotal(data.total ?? 0);
39
+ })
40
+ .catch(() => {});
41
+ }, [page, filterPriority]);
42
+
43
+ const sorted = [...errors].sort((a, b) => {
44
+ if (sortField === 'occurrence_count') return b.occurrence_count - a.occurrence_count;
45
+ if (sortField === 'last_seen_at') return new Date(b.last_seen_at).getTime() - new Date(a.last_seen_at).getTime();
46
+ return 0; // Default server sort by priority
47
+ });
48
+
49
+ return (
50
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
51
+ <div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
52
+ <h3 className="text-sm font-medium text-gray-900 dark:text-white">
53
+ Errors ({total})
54
+ </h3>
55
+ <select
56
+ value={filterPriority}
57
+ onChange={(e) => { setFilterPriority(e.target.value); setPage(1); }}
58
+ className="text-sm border border-gray-300 dark:border-gray-600 rounded px-2 py-1 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
59
+ >
60
+ <option value="">All Priorities</option>
61
+ <option value="P0">P0</option>
62
+ <option value="P1">P1</option>
63
+ <option value="P2">P2</option>
64
+ <option value="P3">P3</option>
65
+ <option value="P4">P4</option>
66
+ </select>
67
+ </div>
68
+
69
+ <div className="overflow-x-auto">
70
+ <table className="w-full text-sm">
71
+ <thead>
72
+ <tr className="border-b border-gray-200 dark:border-gray-700">
73
+ <th className="text-left py-2 px-4 text-gray-500 dark:text-gray-400 font-medium">Priority</th>
74
+ <th className="text-left py-2 px-4 text-gray-500 dark:text-gray-400 font-medium">Message</th>
75
+ <th className="text-left py-2 px-4 text-gray-500 dark:text-gray-400 font-medium">Worker</th>
76
+ <th
77
+ className="text-right py-2 px-4 text-gray-500 dark:text-gray-400 font-medium cursor-pointer hover:text-gray-700 dark:hover:text-gray-200"
78
+ onClick={() => setSortField('occurrence_count')}
79
+ >
80
+ Count {sortField === 'occurrence_count' ? '↓' : ''}
81
+ </th>
82
+ <th
83
+ className="text-right py-2 px-4 text-gray-500 dark:text-gray-400 font-medium cursor-pointer hover:text-gray-700 dark:hover:text-gray-200"
84
+ onClick={() => setSortField('last_seen_at')}
85
+ >
86
+ Last Seen {sortField === 'last_seen_at' ? '↓' : ''}
87
+ </th>
88
+ </tr>
89
+ </thead>
90
+ <tbody>
91
+ {sorted.map((err) => (
92
+ <tr key={err.fingerprint} className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50">
93
+ <td className="py-2 px-4">
94
+ <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${priorityColours[err.priority] ?? priorityColours.P4}`}>
95
+ {err.priority}
96
+ </span>
97
+ </td>
98
+ <td className="py-2 px-4 text-gray-900 dark:text-white max-w-md truncate">{err.message}</td>
99
+ <td className="py-2 px-4 text-gray-500 dark:text-gray-400 font-mono text-xs">{err.script_name}</td>
100
+ <td className="py-2 px-4 text-right text-gray-900 dark:text-white">{err.occurrence_count}</td>
101
+ <td className="py-2 px-4 text-right text-gray-500 dark:text-gray-400 text-xs">
102
+ {new Date(err.last_seen_at).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
103
+ </td>
104
+ </tr>
105
+ ))}
106
+ </tbody>
107
+ </table>
108
+ </div>
109
+
110
+ {total > 20 && (
111
+ <div className="p-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
112
+ <button
113
+ onClick={() => setPage(p => Math.max(1, p - 1))}
114
+ disabled={page === 1}
115
+ className="text-sm text-blue-600 dark:text-blue-400 disabled:opacity-50"
116
+ >
117
+ Previous
118
+ </button>
119
+ <span className="text-sm text-gray-500 dark:text-gray-400">
120
+ Page {page} of {Math.ceil(total / 20)}
121
+ </span>
122
+ <button
123
+ onClick={() => setPage(p => p + 1)}
124
+ disabled={page >= Math.ceil(total / 20)}
125
+ className="text-sm text-blue-600 dark:text-blue-400 disabled:opacity-50"
126
+ >
127
+ Next
128
+ </button>
129
+ </div>
130
+ )}
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,2 @@
1
+ export { ErrorsTable } from './ErrorsTable';
2
+ export { ErrorStats } from './ErrorStats';
@@ -0,0 +1,52 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ interface DlqStatus {
4
+ depth: number;
5
+ oldestAgeMinutes: number | null;
6
+ status: 'healthy' | 'warning' | 'critical' | 'unknown';
7
+ }
8
+
9
+ export function DlqStatusCard() {
10
+ const [data, setData] = useState<DlqStatus | null>(null);
11
+
12
+ useEffect(() => {
13
+ fetch('/api/health/dlq')
14
+ .then(res => res.json())
15
+ .then((dlq: DlqStatus) => setData(dlq))
16
+ .catch(() => {});
17
+ }, []);
18
+
19
+ const statusColour = data?.status === 'healthy'
20
+ ? 'text-green-600 dark:text-green-400'
21
+ : data?.status === 'warning'
22
+ ? 'text-yellow-600 dark:text-yellow-400'
23
+ : data?.status === 'critical'
24
+ ? 'text-red-600 dark:text-red-400'
25
+ : 'text-gray-500 dark:text-gray-400';
26
+
27
+ return (
28
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
29
+ <h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
30
+ Dead Letter Queue
31
+ </h3>
32
+ <div className="grid grid-cols-3 gap-4">
33
+ <div>
34
+ <div className="text-2xl font-bold text-gray-900 dark:text-white">{data?.depth ?? 0}</div>
35
+ <div className="text-xs text-gray-500 dark:text-gray-400">Pending</div>
36
+ </div>
37
+ <div>
38
+ <div className="text-2xl font-bold text-gray-900 dark:text-white">
39
+ {data?.oldestAgeMinutes != null ? `${data.oldestAgeMinutes}m` : '\u2014'}
40
+ </div>
41
+ <div className="text-xs text-gray-500 dark:text-gray-400">Oldest</div>
42
+ </div>
43
+ <div>
44
+ <div className={`text-2xl font-bold ${statusColour}`}>
45
+ {data?.status ?? 'Unknown'}
46
+ </div>
47
+ <div className="text-xs text-gray-500 dark:text-gray-400">Status</div>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,86 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { DlqStatusCard } from './DlqStatusCard';
3
+
4
+ type Tab = 'overview' | 'workers' | 'queues' | 'storage';
5
+
6
+ export function HealthTabs() {
7
+ const [activeTab, setActiveTab] = useState<Tab>('overview');
8
+ const [cbData, setCbData] = useState<Array<{
9
+ key: string;
10
+ feature: string;
11
+ status: string;
12
+ reason: string | null;
13
+ }>>([]);
14
+
15
+ useEffect(() => {
16
+ fetch('/api/usage/circuit-breakers')
17
+ .then(res => res.json())
18
+ .then((data: { breakers: typeof cbData }) => setCbData(data.breakers ?? []))
19
+ .catch(() => {});
20
+ }, []);
21
+
22
+ const tabs: { id: Tab; label: string }[] = [
23
+ { id: 'overview', label: 'Overview' },
24
+ { id: 'workers', label: 'Workers' },
25
+ { id: 'queues', label: 'Queues' },
26
+ { id: 'storage', label: 'Storage' },
27
+ ];
28
+
29
+ return (
30
+ <div>
31
+ <div className="border-b border-gray-200 dark:border-gray-700 mb-4">
32
+ <nav className="flex gap-4">
33
+ {tabs.map(tab => (
34
+ <button
35
+ key={tab.id}
36
+ onClick={() => setActiveTab(tab.id)}
37
+ className={`py-2 px-1 text-sm font-medium border-b-2 transition-colors ${
38
+ activeTab === tab.id
39
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
40
+ : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
41
+ }`}
42
+ >
43
+ {tab.label}
44
+ </button>
45
+ ))}
46
+ </nav>
47
+ </div>
48
+
49
+ {activeTab === 'overview' && (
50
+ <div className="space-y-4">
51
+ <DlqStatusCard />
52
+ {/* Circuit Breakers */}
53
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
54
+ <h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
55
+ Circuit Breakers
56
+ </h3>
57
+ {cbData.length === 0 ? (
58
+ <p className="text-sm text-gray-500 dark:text-gray-400">No circuit breakers configured.</p>
59
+ ) : (
60
+ <div className="space-y-2">
61
+ {cbData.map(cb => (
62
+ <div key={cb.key} className="flex items-center justify-between py-1">
63
+ <span className="text-sm text-gray-900 dark:text-white">{cb.feature}</span>
64
+ <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
65
+ cb.status === 'active' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
66
+ : cb.status === 'warning' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
67
+ : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
68
+ }`}>
69
+ {cb.status}
70
+ </span>
71
+ </div>
72
+ ))}
73
+ </div>
74
+ )}
75
+ </div>
76
+ </div>
77
+ )}
78
+
79
+ {activeTab !== 'overview' && (
80
+ <div className="text-center py-8 text-gray-500 dark:text-gray-400">
81
+ <p>{activeTab} health details will appear here once monitoring data is collected.</p>
82
+ </div>
83
+ )}
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,2 @@
1
+ export { HealthTabs } from './HealthTabs';
2
+ export { DlqStatusCard } from './DlqStatusCard';
@@ -0,0 +1,28 @@
1
+ export interface ErrorOccurrence {
2
+ fingerprint: string;
3
+ message: string;
4
+ script_name: string;
5
+ priority: string;
6
+ occurrence_count: number;
7
+ status: string;
8
+ first_seen_at: string;
9
+ last_seen_at: string;
10
+ github_issue_url: string | null;
11
+ }
12
+
13
+ export interface ErrorStats {
14
+ byPriority: Record<string, number>;
15
+ byWorker: Array<{ script_name: string; count: number }>;
16
+ total: number;
17
+ }
18
+
19
+ export function getPriorityColour(priority: string): string {
20
+ const colours: Record<string, string> = {
21
+ P0: 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300',
22
+ P1: 'bg-orange-100 text-orange-800 dark:bg-orange-900/50 dark:text-orange-300',
23
+ P2: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300',
24
+ P3: 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300',
25
+ P4: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
26
+ };
27
+ return colours[priority] ?? colours.P4;
28
+ }
@@ -0,0 +1,58 @@
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({ errors: [], total: 0 }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ const page = parseInt(url.searchParams.get('page') ?? '1');
13
+ const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '20'), 100);
14
+ const offset = (page - 1) * limit;
15
+ const priority = url.searchParams.get('priority');
16
+ const status = url.searchParams.get('status') ?? 'open';
17
+
18
+ try {
19
+ let query = `SELECT fingerprint, normalized_message as message, script_name, priority,
20
+ occurrence_count, status, first_seen_at, last_seen_at, github_issue_url
21
+ FROM error_occurrences WHERE status = ?`;
22
+ const params: unknown[] = [status];
23
+
24
+ if (priority) {
25
+ query += ` AND priority = ?`;
26
+ params.push(priority);
27
+ }
28
+
29
+ query += ` ORDER BY
30
+ CASE priority WHEN 'P0' THEN 1 WHEN 'P1' THEN 2 WHEN 'P2' THEN 3 WHEN 'P3' THEN 4 ELSE 5 END,
31
+ last_seen_at DESC
32
+ LIMIT ? OFFSET ?`;
33
+ params.push(limit, offset);
34
+
35
+ const errors = await db.prepare(query).bind(...params).all();
36
+
37
+ const countQuery = priority
38
+ ? `SELECT COUNT(*) as total FROM error_occurrences WHERE status = ? AND priority = ? LIMIT 1`
39
+ : `SELECT COUNT(*) as total FROM error_occurrences WHERE status = ? LIMIT 1`;
40
+ const countParams = priority ? [status, priority] : [status];
41
+ const countResult = await db.prepare(countQuery).bind(...countParams).first<{ total: number }>();
42
+
43
+ return new Response(
44
+ JSON.stringify({
45
+ errors: errors.results ?? [],
46
+ total: countResult?.total ?? 0,
47
+ page,
48
+ limit,
49
+ }),
50
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=30' } }
51
+ );
52
+ } catch {
53
+ return new Response(JSON.stringify({ errors: [], total: 0, error: 'Query failed' }), {
54
+ status: 500,
55
+ headers: { 'Content-Type': 'application/json' },
56
+ });
57
+ }
58
+ };
@@ -0,0 +1,55 @@
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({ byPriority: {}, byWorker: [], total: 0 }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ try {
13
+ const byPriority = await db
14
+ .prepare(
15
+ `SELECT priority, COUNT(*) as count
16
+ FROM error_occurrences WHERE status = 'open'
17
+ GROUP BY priority
18
+ LIMIT 10`
19
+ )
20
+ .all<{ priority: string; count: number }>();
21
+
22
+ const byWorker = await db
23
+ .prepare(
24
+ `SELECT script_name, COUNT(*) as count
25
+ FROM error_occurrences WHERE status = 'open'
26
+ GROUP BY script_name
27
+ ORDER BY count DESC
28
+ LIMIT 10`
29
+ )
30
+ .all<{ script_name: string; count: number }>();
31
+
32
+ const total = await db
33
+ .prepare(`SELECT COUNT(*) as count FROM error_occurrences WHERE status = 'open' LIMIT 1`)
34
+ .first<{ count: number }>();
35
+
36
+ const priorityMap: Record<string, number> = {};
37
+ for (const row of byPriority.results ?? []) {
38
+ priorityMap[row.priority] = row.count;
39
+ }
40
+
41
+ return new Response(
42
+ JSON.stringify({
43
+ byPriority: priorityMap,
44
+ byWorker: byWorker.results ?? [],
45
+ total: total?.count ?? 0,
46
+ }),
47
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=30' } }
48
+ );
49
+ } catch {
50
+ return new Response(JSON.stringify({ byPriority: {}, byWorker: [], total: 0 }), {
51
+ status: 500,
52
+ headers: { 'Content-Type': 'application/json' },
53
+ });
54
+ }
55
+ };
@@ -0,0 +1,43 @@
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({ depth: 0, oldestAge: null, status: 'unknown' }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ try {
13
+ const depth = await db
14
+ .prepare(`SELECT COUNT(*) as count FROM dlq_messages WHERE status = 'pending' LIMIT 1`)
15
+ .first<{ count: number }>();
16
+
17
+ const oldest = await db
18
+ .prepare(
19
+ `SELECT MIN(received_at) as oldest
20
+ FROM dlq_messages WHERE status = 'pending'
21
+ LIMIT 1`
22
+ )
23
+ .first<{ oldest: string | null }>();
24
+
25
+ let oldestAgeMinutes: number | null = null;
26
+ if (oldest?.oldest) {
27
+ oldestAgeMinutes = Math.round((Date.now() - new Date(oldest.oldest).getTime()) / 60000);
28
+ }
29
+
30
+ const dlqDepth = depth?.count ?? 0;
31
+ const status = dlqDepth === 0 ? 'healthy' : dlqDepth < 100 ? 'warning' : 'critical';
32
+
33
+ return new Response(
34
+ JSON.stringify({ depth: dlqDepth, oldestAgeMinutes, status }),
35
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=30' } }
36
+ );
37
+ } catch {
38
+ return new Response(JSON.stringify({ depth: 0, oldestAgeMinutes: null, status: 'unknown' }), {
39
+ status: 500,
40
+ headers: { 'Content-Type': 'application/json' },
41
+ });
42
+ }
43
+ };
@@ -0,0 +1,13 @@
1
+ ---
2
+ import DashboardLayout from '../../layouts/DashboardLayout.astro';
3
+ import { ErrorsTable } from '../../components/errors/ErrorsTable';
4
+ import { ErrorStats } from '../../components/errors/ErrorStats';
5
+ ---
6
+
7
+ <DashboardLayout title="Errors">
8
+ <div class="max-w-7xl mx-auto space-y-6">
9
+ <h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Error Management</h2>
10
+ <ErrorStats client:load />
11
+ <ErrorsTable client:load />
12
+ </div>
13
+ </DashboardLayout>
@@ -0,0 +1,11 @@
1
+ ---
2
+ import DashboardLayout from '../../layouts/DashboardLayout.astro';
3
+ import { HealthTabs } from '../../components/health/HealthTabs';
4
+ ---
5
+
6
+ <DashboardLayout title="Health">
7
+ <div class="max-w-7xl mx-auto">
8
+ <h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">System Health</h2>
9
+ <HealthTabs client:load />
10
+ </div>
11
+ </DashboardLayout>
@@ -0,0 +1,65 @@
1
+ -- Migration 009: Topology Mapper Tables
2
+ --
3
+ -- Purpose: Infrastructure topology tracking for the platform-mapper worker.
4
+ -- Stores discovered Cloudflare resources, service connections, and historical snapshots.
5
+ --
6
+ -- Tables:
7
+ -- services — Discovered/defined infrastructure services
8
+ -- connections — Relationships between services
9
+ -- topology_snapshots — Historical topology snapshots for trend analysis
10
+ --
11
+ -- Tier: Standard+
12
+ -- Worker: platform-mapper (15-minute cron)
13
+
14
+ -- Services discovered or defined
15
+ CREATE TABLE IF NOT EXISTS services (
16
+ id TEXT PRIMARY KEY,
17
+ name TEXT NOT NULL,
18
+ type TEXT NOT NULL, -- 'cloudflare-worker', 'cloudflare-pages', etc.
19
+ tier INTEGER NOT NULL, -- 0 (critical), 1 (high), 2 (medium)
20
+ status TEXT NOT NULL, -- 'deployed', 'partial', 'not-deployed', 'unknown'
21
+ version TEXT,
22
+ health_endpoint TEXT,
23
+ health_status TEXT DEFAULT 'unknown', -- 'up', 'degraded', 'down', 'unknown'
24
+ last_seen TIMESTAMP,
25
+ metadata TEXT, -- JSON: Type-specific data
26
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
27
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
28
+ );
29
+
30
+ CREATE INDEX IF NOT EXISTS idx_services_type ON services(type);
31
+ CREATE INDEX IF NOT EXISTS idx_services_tier ON services(tier);
32
+ CREATE INDEX IF NOT EXISTS idx_services_status ON services(status);
33
+ CREATE INDEX IF NOT EXISTS idx_services_health ON services(health_status);
34
+
35
+ -- Connections between services
36
+ CREATE TABLE IF NOT EXISTS connections (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ from_service TEXT NOT NULL,
39
+ to_service TEXT NOT NULL,
40
+ connection_type TEXT NOT NULL, -- 'webhook', 'api', 'data-flow', 'deployment', 'service-binding'
41
+ protocol TEXT, -- 'https', 'rest', 'queue', etc.
42
+ status TEXT NOT NULL, -- 'active', 'planned', 'not-integrated', 'broken'
43
+ metadata TEXT, -- JSON: Connection-specific data
44
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
45
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
46
+ UNIQUE (from_service, to_service, connection_type),
47
+ FOREIGN KEY (from_service) REFERENCES services(id),
48
+ FOREIGN KEY (to_service) REFERENCES services(id)
49
+ );
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_connections_from ON connections(from_service);
52
+ CREATE INDEX IF NOT EXISTS idx_connections_to ON connections(to_service);
53
+ CREATE INDEX IF NOT EXISTS idx_connections_status ON connections(status);
54
+
55
+ -- Historical snapshots (for topology change tracking)
56
+ CREATE TABLE IF NOT EXISTS topology_snapshots (
57
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
59
+ data TEXT NOT NULL, -- JSON: Full topology snapshot
60
+ change_summary TEXT, -- What changed since last snapshot
61
+ service_count INTEGER,
62
+ connection_count INTEGER
63
+ );
64
+
65
+ CREATE INDEX IF NOT EXISTS idx_snapshots_timestamp ON topology_snapshots(timestamp);
@@ -198,14 +198,48 @@ export async function processEmailHealthAlerts(
198
198
  }
199
199
 
200
200
  try {
201
- // Create a focused issue for this specific check failure
201
+ const issueTitle = `Email Health: ${brandName} ${failure.check_type} failing`;
202
+
203
+ // Search for an existing OPEN issue before creating a new one.
204
+ // This prevents daily duplicate issues when a problem persists across days.
205
+ const openIssues = await github.searchIssues(
206
+ owner,
207
+ repo,
208
+ `"${issueTitle}" is:open label:cf:email-health`
209
+ );
210
+
211
+ if (openIssues.length > 0) {
212
+ const existing = openIssues[0];
213
+ await github.addComment(
214
+ owner,
215
+ repo,
216
+ existing.number,
217
+ `### Still failing (${getDateKey()})\n\n` +
218
+ `Check \`${failure.check_type}\` continues to fail: ${failure.error_msg}\n\n` +
219
+ `Run ID: \`${event.run_id}\``
220
+ );
221
+ await setDedup(env.PLATFORM_CACHE, event.brand_id, failure.check_type, existing.number);
222
+
223
+ console.log(
224
+ `Commented on existing email health issue #${existing.number} for ${event.brand_id}:${failure.check_type}`
225
+ );
226
+
227
+ results.skipped++;
228
+ results.skippedChecks.push({
229
+ check_type: failure.check_type,
230
+ reason: `Commented on existing issue #${existing.number}`,
231
+ });
232
+ continue;
233
+ }
234
+
235
+ // No existing open issue — create a new one
202
236
  const issue = await github.createIssue({
203
237
  owner,
204
238
  repo,
205
- title: `Email Health: ${brandName} ${failure.check_type} failing`,
239
+ title: issueTitle,
206
240
  body: formatIssueBody({
207
241
  ...event,
208
- failures: [failure], // Only include this specific failure
242
+ failures: [failure],
209
243
  }),
210
244
  labels: EMAIL_HEALTH_LABELS,
211
245
  });
@@ -237,7 +237,38 @@ export async function processGapAlert(
237
237
  const github = new GitHubClient(env);
238
238
 
239
239
  try {
240
- // Create the issue
240
+ // Search for an existing OPEN gap alert issue before creating a new one.
241
+ // This prevents daily duplicate issues when coverage stays below threshold.
242
+ const openIssues = await github.searchIssues(
243
+ owner,
244
+ repo,
245
+ `"Data Coverage Gap: ${event.project}" is:open label:cf:gap-alert`
246
+ );
247
+
248
+ if (openIssues.length > 0) {
249
+ const existing = openIssues[0];
250
+ await github.addComment(
251
+ owner,
252
+ repo,
253
+ existing.number,
254
+ `### Coverage still below threshold (${getDateKey()})\n\n` +
255
+ `Coverage: **${event.coveragePct}%** (threshold: 90%)\n` +
256
+ `Hours with data: ${event.hoursWithData}/${event.expectedHours}\n` +
257
+ `Missing hours: ${event.missingHours.length}`
258
+ );
259
+ await setGapAlertDedup(env.PLATFORM_CACHE, event.project, existing.number);
260
+
261
+ console.log(
262
+ `Commented on existing gap alert issue #${existing.number} for ${event.project}`
263
+ );
264
+
265
+ return {
266
+ processed: false,
267
+ skipped: `Commented on existing issue #${existing.number}`,
268
+ };
269
+ }
270
+
271
+ // No existing open issue — create a new one
241
272
  const issue = await github.createIssue({
242
273
  owner,
243
274
  repo,