@dupecom/botcha-cloudflare 0.3.3 → 0.10.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 (47) hide show
  1. package/dist/analytics.d.ts +60 -0
  2. package/dist/analytics.d.ts.map +1 -0
  3. package/dist/analytics.js +130 -0
  4. package/dist/apps.d.ts +159 -0
  5. package/dist/apps.d.ts.map +1 -0
  6. package/dist/apps.js +307 -0
  7. package/dist/auth.d.ts +93 -6
  8. package/dist/auth.d.ts.map +1 -1
  9. package/dist/auth.js +251 -9
  10. package/dist/challenges.d.ts +31 -7
  11. package/dist/challenges.d.ts.map +1 -1
  12. package/dist/challenges.js +551 -144
  13. package/dist/dashboard/api.d.ts +70 -0
  14. package/dist/dashboard/api.d.ts.map +1 -0
  15. package/dist/dashboard/api.js +546 -0
  16. package/dist/dashboard/auth.d.ts +183 -0
  17. package/dist/dashboard/auth.d.ts.map +1 -0
  18. package/dist/dashboard/auth.js +401 -0
  19. package/dist/dashboard/device-code.d.ts +43 -0
  20. package/dist/dashboard/device-code.d.ts.map +1 -0
  21. package/dist/dashboard/device-code.js +77 -0
  22. package/dist/dashboard/index.d.ts +31 -0
  23. package/dist/dashboard/index.d.ts.map +1 -0
  24. package/dist/dashboard/index.js +64 -0
  25. package/dist/dashboard/layout.d.ts +47 -0
  26. package/dist/dashboard/layout.d.ts.map +1 -0
  27. package/dist/dashboard/layout.js +38 -0
  28. package/dist/dashboard/pages.d.ts +11 -0
  29. package/dist/dashboard/pages.d.ts.map +1 -0
  30. package/dist/dashboard/pages.js +18 -0
  31. package/dist/dashboard/styles.d.ts +11 -0
  32. package/dist/dashboard/styles.d.ts.map +1 -0
  33. package/dist/dashboard/styles.js +633 -0
  34. package/dist/email.d.ts +44 -0
  35. package/dist/email.d.ts.map +1 -0
  36. package/dist/email.js +119 -0
  37. package/dist/index.d.ts +3 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +644 -50
  40. package/dist/rate-limit.d.ts +11 -1
  41. package/dist/rate-limit.d.ts.map +1 -1
  42. package/dist/rate-limit.js +13 -2
  43. package/dist/routes/stream.js +1 -1
  44. package/dist/static.d.ts +728 -0
  45. package/dist/static.d.ts.map +1 -0
  46. package/dist/static.js +818 -0
  47. package/package.json +1 -1
@@ -0,0 +1,70 @@
1
+ /**
2
+ * BOTCHA Dashboard — Analytics Engine Query API
3
+ *
4
+ * Server-side endpoints that query CF Analytics Engine SQL API
5
+ * and return HTML fragments for htmx to swap in.
6
+ *
7
+ * Data schema (from analytics.ts writeDataPoint):
8
+ * blobs[0] = eventType (challenge_generated | challenge_verified | auth_success | auth_failure | rate_limit_exceeded | error)
9
+ * blobs[1] = challengeType (speed | standard | reasoning | hybrid | '')
10
+ * blobs[2] = endpoint
11
+ * blobs[3] = verificationResult (success | failure | '')
12
+ * blobs[4] = authMethod
13
+ * blobs[5] = clientIP
14
+ * blobs[6] = country
15
+ * blobs[7] = errorType
16
+ * doubles[0] = solveTimeMs
17
+ * doubles[1] = responseTimeMs
18
+ * indexes[0] = eventType
19
+ * indexes[1] = challengeType or 'none'
20
+ * indexes[2] = endpoint or 'unknown'
21
+ */
22
+ import type { Context } from 'hono';
23
+ interface DashboardEnv {
24
+ Bindings: {
25
+ ANALYTICS?: AnalyticsEngineDataset;
26
+ CF_API_TOKEN?: string;
27
+ CF_ACCOUNT_ID?: string;
28
+ [key: string]: unknown;
29
+ };
30
+ [key: string]: unknown;
31
+ }
32
+ type AnalyticsEngineDataset = {
33
+ writeDataPoint: (data: {
34
+ blobs?: string[];
35
+ doubles?: number[];
36
+ indexes?: string[];
37
+ }) => void;
38
+ };
39
+ /**
40
+ * GET /dashboard/api/overview?period=24h
41
+ * Returns HTML fragment with key stats: total challenges, verifications, success rate, avg solve time
42
+ */
43
+ export declare function handleOverview(c: Context<DashboardEnv>, appId: string): Promise<Response>;
44
+ /**
45
+ * GET /dashboard/api/volume?period=24h
46
+ * Returns HTML fragment with time-bucketed event volume
47
+ */
48
+ export declare function handleVolume(c: Context<DashboardEnv>, appId: string): Promise<Response>;
49
+ /**
50
+ * GET /dashboard/api/types?period=24h
51
+ * Returns HTML fragment with challenge type breakdown
52
+ */
53
+ export declare function handleTypes(c: Context<DashboardEnv>, appId: string): Promise<Response>;
54
+ /**
55
+ * GET /dashboard/api/performance?period=24h
56
+ * Returns HTML fragment with performance metrics (solve times, response times)
57
+ */
58
+ export declare function handlePerformance(c: Context<DashboardEnv>, appId: string): Promise<Response>;
59
+ /**
60
+ * GET /dashboard/api/errors?period=24h
61
+ * Returns HTML fragment with error breakdown
62
+ */
63
+ export declare function handleErrors(c: Context<DashboardEnv>, appId: string): Promise<Response>;
64
+ /**
65
+ * GET /dashboard/api/geo?period=24h
66
+ * Returns HTML fragment with geographic distribution
67
+ */
68
+ export declare function handleGeo(c: Context<DashboardEnv>, appId: string): Promise<Response>;
69
+ export {};
70
+ //# sourceMappingURL=api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/dashboard/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAKpC,UAAU,YAAY;IACpB,QAAQ,EAAE;QACR,SAAS,CAAC,EAAE,sBAAsB,CAAC;QACnC,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;IACF,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,KAAK,sBAAsB,GAAG;IAC5B,cAAc,EAAE,CAAC,IAAI,EAAE;QACrB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;QACnB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;KACpB,KAAK,IAAI,CAAC;CACZ,CAAC;AA2KF;;;GAGG;AACH,wBAAsB,cAAc,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,EAAE,KAAK,EAAE,MAAM,qBA4D3E;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,EAAE,KAAK,EAAE,MAAM,qBAgDzE;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,EAAE,KAAK,EAAE,MAAM,qBA8CxE;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,EAAE,KAAK,EAAE,MAAM,qBAyE9E;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,EAAE,KAAK,EAAE,MAAM,qBA8CzE;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,EAAE,KAAK,EAAE,MAAM,qBA4CtE"}
@@ -0,0 +1,546 @@
1
+ /**
2
+ * BOTCHA Dashboard — Analytics Engine Query API
3
+ *
4
+ * Server-side endpoints that query CF Analytics Engine SQL API
5
+ * and return HTML fragments for htmx to swap in.
6
+ *
7
+ * Data schema (from analytics.ts writeDataPoint):
8
+ * blobs[0] = eventType (challenge_generated | challenge_verified | auth_success | auth_failure | rate_limit_exceeded | error)
9
+ * blobs[1] = challengeType (speed | standard | reasoning | hybrid | '')
10
+ * blobs[2] = endpoint
11
+ * blobs[3] = verificationResult (success | failure | '')
12
+ * blobs[4] = authMethod
13
+ * blobs[5] = clientIP
14
+ * blobs[6] = country
15
+ * blobs[7] = errorType
16
+ * doubles[0] = solveTimeMs
17
+ * doubles[1] = responseTimeMs
18
+ * indexes[0] = eventType
19
+ * indexes[1] = challengeType or 'none'
20
+ * indexes[2] = endpoint or 'unknown'
21
+ */
22
+ function periodToInterval(period) {
23
+ // CF Analytics Engine SQL requires quoted numbers: INTERVAL '24' HOUR
24
+ switch (period) {
25
+ case '1h':
26
+ return "'1' HOUR";
27
+ case '24h':
28
+ return "'24' HOUR";
29
+ case '7d':
30
+ return "'7' DAY";
31
+ case '30d':
32
+ return "'30' DAY";
33
+ default:
34
+ return "'24' HOUR";
35
+ }
36
+ }
37
+ function parsePeriod(value) {
38
+ if (value === '1h' || value === '24h' || value === '7d' || value === '30d') {
39
+ return value;
40
+ }
41
+ return '24h';
42
+ }
43
+ // ============ SQL QUERY HELPER ============
44
+ const CF_AE_BASE = 'https://api.cloudflare.com/client/v4/accounts';
45
+ /**
46
+ * Query Cloudflare Analytics Engine SQL API
47
+ */
48
+ async function queryAnalyticsEngine(sql, accountId, apiToken) {
49
+ const url = `${CF_AE_BASE}/${accountId}/analytics_engine/sql`;
50
+ try {
51
+ const resp = await fetch(url, {
52
+ method: 'POST',
53
+ headers: {
54
+ Authorization: `Bearer ${apiToken}`,
55
+ 'Content-Type': 'text/plain',
56
+ },
57
+ body: sql,
58
+ });
59
+ if (!resp.ok) {
60
+ const text = await resp.text();
61
+ console.error('Analytics Engine query failed:', resp.status, text);
62
+ return { data: [], rows: 0, error: `HTTP ${resp.status}: ${text.substring(0, 200)}` };
63
+ }
64
+ // AE SQL API returns newline-delimited JSON (first line is metadata, second is data)
65
+ const text = await resp.text();
66
+ // Try parsing as JSON first (newer AE format)
67
+ try {
68
+ const json = JSON.parse(text);
69
+ if (json.data) {
70
+ return { data: json.data, rows: json.data.length };
71
+ }
72
+ // Some responses have a `rows` property
73
+ if (Array.isArray(json)) {
74
+ return { data: json, rows: json.length };
75
+ }
76
+ }
77
+ catch {
78
+ // Not JSON — try CSV-like parsing for older AE format
79
+ }
80
+ // Parse tab-separated response with header row
81
+ const lines = text.trim().split('\n');
82
+ if (lines.length < 2) {
83
+ return { data: [], rows: 0 };
84
+ }
85
+ const headers = lines[0].split('\t').map(h => h.trim());
86
+ const data = [];
87
+ for (let i = 1; i < lines.length; i++) {
88
+ const values = lines[i].split('\t');
89
+ const row = {};
90
+ for (let j = 0; j < headers.length; j++) {
91
+ const val = values[j]?.trim() ?? '';
92
+ // Try to parse as number
93
+ const num = Number(val);
94
+ row[headers[j]] = isNaN(num) || val === '' ? val : num;
95
+ }
96
+ data.push(row);
97
+ }
98
+ return { data, rows: data.length };
99
+ }
100
+ catch (error) {
101
+ console.error('Analytics Engine query error:', error);
102
+ return { data: [], rows: 0, error: error instanceof Error ? error.message : 'Unknown error' };
103
+ }
104
+ }
105
+ // ============ HTML FRAGMENT HELPERS ============
106
+ function formatNumber(n) {
107
+ if (n === null || n === undefined)
108
+ return '0';
109
+ if (n >= 1_000_000)
110
+ return `${(n / 1_000_000).toFixed(1)}M`;
111
+ if (n >= 1_000)
112
+ return `${(n / 1_000).toFixed(1)}K`;
113
+ return n.toLocaleString('en-US');
114
+ }
115
+ function renderStatCard(value, label, cssClass) {
116
+ return `<div class="stat-card">
117
+ <span class="stat-value ${cssClass || ''}">${value}</span>
118
+ <span class="stat-label">${label}</span>
119
+ </div>`;
120
+ }
121
+ function renderBarChart(items) {
122
+ if (items.length === 0) {
123
+ return renderEmptyState('No data for this period');
124
+ }
125
+ let html = '<div class="bar-chart">';
126
+ for (const item of items) {
127
+ const pct = item.maxValue > 0 ? Math.round((item.value / item.maxValue) * 100) : 0;
128
+ html += `<div class="bar-item">
129
+ <div class="bar-label">
130
+ <span class="bar-name">${escapeHtml(item.name)}</span>
131
+ <span class="bar-value">${formatNumber(item.value)}</span>
132
+ </div>
133
+ <div class="bar" style="width: ${Math.max(pct, 2)}%"></div>
134
+ </div>`;
135
+ }
136
+ html += '</div>';
137
+ return html;
138
+ }
139
+ function renderEmptyState(message) {
140
+ return `<div class="empty-state">
141
+ <div class="empty-state-icon">&gt;_</div>
142
+ <div class="empty-state-text">${escapeHtml(message)}</div>
143
+ <div class="empty-state-subtext">Try a longer time period or generate some challenges first</div>
144
+ </div>`;
145
+ }
146
+ function renderError(message) {
147
+ return `<div class="alert alert-danger">${escapeHtml(message)}</div>`;
148
+ }
149
+ function escapeHtml(str) {
150
+ return str
151
+ .replace(/&/g, '&amp;')
152
+ .replace(/</g, '&lt;')
153
+ .replace(/>/g, '&gt;')
154
+ .replace(/"/g, '&quot;');
155
+ }
156
+ // ============ API ENDPOINT HANDLERS ============
157
+ /**
158
+ * GET /dashboard/api/overview?period=24h
159
+ * Returns HTML fragment with key stats: total challenges, verifications, success rate, avg solve time
160
+ */
161
+ export async function handleOverview(c, appId) {
162
+ const period = parsePeriod(c.req.query('period'));
163
+ const interval = periodToInterval(period);
164
+ const accountId = c.env.CF_ACCOUNT_ID;
165
+ const apiToken = c.env.CF_API_TOKEN;
166
+ if (!accountId || !apiToken) {
167
+ return c.html(renderMockOverview(period));
168
+ }
169
+ const sql = `
170
+ SELECT
171
+ count() as total_events,
172
+ countIf(blob1 = 'challenge_generated') as challenges_generated,
173
+ countIf(blob1 = 'challenge_verified') as verifications,
174
+ countIf(blob1 = 'challenge_verified' AND blob4 = 'success') as successful_verifications,
175
+ countIf(blob1 = 'auth_success') as auth_successes,
176
+ countIf(blob1 = 'rate_limit_exceeded') as rate_limits,
177
+ countIf(blob1 = 'error') as errors,
178
+ avg(double1) as avg_solve_time_ms,
179
+ avg(double2) as avg_response_time_ms
180
+ FROM botcha
181
+ WHERE timestamp >= now() - INTERVAL ${interval}
182
+ FORMAT JSON
183
+ `;
184
+ const result = await queryAnalyticsEngine(sql, accountId, apiToken);
185
+ if (result.error) {
186
+ return c.html(renderMockOverview(period));
187
+ }
188
+ const row = result.data[0] || {};
189
+ const totalChallenges = Number(row.challenges_generated || 0);
190
+ const totalVerifications = Number(row.verifications || 0);
191
+ const successfulVerifications = Number(row.successful_verifications || 0);
192
+ const rateLimits = Number(row.rate_limits || 0);
193
+ const errors = Number(row.errors || 0);
194
+ const avgSolveTime = Number(row.avg_solve_time_ms || 0);
195
+ // No real data — show mock data so dashboard isn't empty in local dev
196
+ const totalEvents = Number(row.total_events || 0);
197
+ if (totalEvents === 0) {
198
+ return c.html(renderMockOverview(period));
199
+ }
200
+ const successRate = totalVerifications > 0
201
+ ? Math.round((successfulVerifications / totalVerifications) * 100)
202
+ : 0;
203
+ const html = `<div class="dashboard-grid">
204
+ ${renderStatCard(formatNumber(totalChallenges), 'Challenges Generated')}
205
+ ${renderStatCard(formatNumber(totalVerifications), 'Verifications')}
206
+ ${renderStatCard(`${successRate}%`, 'Success Rate', successRate >= 80 ? 'text-success' : successRate >= 50 ? 'text-warning' : 'text-danger')}
207
+ ${renderStatCard(`${Math.round(avgSolveTime)}ms`, 'Avg Solve Time')}
208
+ ${renderStatCard(formatNumber(rateLimits), 'Rate Limits Hit', rateLimits > 0 ? 'text-warning' : '')}
209
+ ${renderStatCard(formatNumber(errors), 'Errors', errors > 0 ? 'text-danger' : '')}
210
+ </div>`;
211
+ return c.html(html);
212
+ }
213
+ /**
214
+ * GET /dashboard/api/volume?period=24h
215
+ * Returns HTML fragment with time-bucketed event volume
216
+ */
217
+ export async function handleVolume(c, appId) {
218
+ const period = parsePeriod(c.req.query('period'));
219
+ const interval = periodToInterval(period);
220
+ const accountId = c.env.CF_ACCOUNT_ID;
221
+ const apiToken = c.env.CF_API_TOKEN;
222
+ if (!accountId || !apiToken) {
223
+ return c.html(renderMockVolume(period));
224
+ }
225
+ // Choose bucket size based on period (CF AE SQL requires quoted numbers)
226
+ const bucket = period === '1h' ? "'5' MINUTE" : period === '24h' ? "'1' HOUR" : "'1' DAY";
227
+ const sql = `
228
+ SELECT
229
+ toStartOfInterval(timestamp, INTERVAL ${bucket}) as time_bucket,
230
+ count() as events,
231
+ countIf(blob1 = 'challenge_generated') as generated,
232
+ countIf(blob1 = 'challenge_verified' AND blob4 = 'success') as verified_ok,
233
+ countIf(blob1 = 'challenge_verified' AND blob4 = 'failure') as verified_fail
234
+ FROM botcha
235
+ WHERE timestamp >= now() - INTERVAL ${interval}
236
+ GROUP BY time_bucket
237
+ ORDER BY time_bucket ASC
238
+ FORMAT JSON
239
+ `;
240
+ const result = await queryAnalyticsEngine(sql, accountId, apiToken);
241
+ if (result.error) {
242
+ return c.html(renderMockVolume(period));
243
+ }
244
+ if (result.data.length === 0) {
245
+ return c.html(renderMockVolume(period));
246
+ }
247
+ const maxEvents = Math.max(...result.data.map(r => Number(r.events || 0)));
248
+ const items = result.data.map(row => ({
249
+ name: formatTimeBucket(String(row.time_bucket || ''), period),
250
+ value: Number(row.events || 0),
251
+ maxValue: maxEvents,
252
+ }));
253
+ return c.html(`<fieldset>
254
+ <legend>Request Volume (${period})</legend>
255
+ ${renderBarChart(items)}
256
+ </fieldset>`);
257
+ }
258
+ /**
259
+ * GET /dashboard/api/types?period=24h
260
+ * Returns HTML fragment with challenge type breakdown
261
+ */
262
+ export async function handleTypes(c, appId) {
263
+ const period = parsePeriod(c.req.query('period'));
264
+ const interval = periodToInterval(period);
265
+ const accountId = c.env.CF_ACCOUNT_ID;
266
+ const apiToken = c.env.CF_API_TOKEN;
267
+ if (!accountId || !apiToken) {
268
+ return c.html(renderMockTypes(period));
269
+ }
270
+ const sql = `
271
+ SELECT
272
+ blob2 as challenge_type,
273
+ count() as total,
274
+ countIf(blob4 = 'success') as successes,
275
+ countIf(blob4 = 'failure') as failures
276
+ FROM botcha
277
+ WHERE timestamp >= now() - INTERVAL ${interval}
278
+ AND blob1 IN ('challenge_generated', 'challenge_verified')
279
+ AND blob2 != ''
280
+ GROUP BY challenge_type
281
+ ORDER BY total DESC
282
+ FORMAT JSON
283
+ `;
284
+ const result = await queryAnalyticsEngine(sql, accountId, apiToken);
285
+ if (result.error) {
286
+ return c.html(renderMockTypes(period));
287
+ }
288
+ if (result.data.length === 0) {
289
+ return c.html(renderMockTypes(period));
290
+ }
291
+ const maxTotal = Math.max(...result.data.map(r => Number(r.total || 0)));
292
+ const items = result.data.map(row => ({
293
+ name: `${String(row.challenge_type || 'unknown')} (${Number(row.successes || 0)} ok / ${Number(row.failures || 0)} fail)`,
294
+ value: Number(row.total || 0),
295
+ maxValue: maxTotal,
296
+ }));
297
+ return c.html(`<fieldset>
298
+ <legend>Challenge Types (${period})</legend>
299
+ ${renderBarChart(items)}
300
+ </fieldset>`);
301
+ }
302
+ /**
303
+ * GET /dashboard/api/performance?period=24h
304
+ * Returns HTML fragment with performance metrics (solve times, response times)
305
+ */
306
+ export async function handlePerformance(c, appId) {
307
+ const period = parsePeriod(c.req.query('period'));
308
+ const interval = periodToInterval(period);
309
+ const accountId = c.env.CF_ACCOUNT_ID;
310
+ const apiToken = c.env.CF_API_TOKEN;
311
+ if (!accountId || !apiToken) {
312
+ return c.html(renderMockPerformance(period));
313
+ }
314
+ const sql = `
315
+ SELECT
316
+ blob2 as challenge_type,
317
+ count() as total,
318
+ avg(double1) as avg_solve_ms,
319
+ min(double1) as min_solve_ms,
320
+ max(double1) as max_solve_ms,
321
+ avg(double2) as avg_response_ms
322
+ FROM botcha
323
+ WHERE timestamp >= now() - INTERVAL ${interval}
324
+ AND blob1 = 'challenge_verified'
325
+ AND blob4 = 'success'
326
+ AND blob2 != ''
327
+ AND double1 > 0
328
+ GROUP BY challenge_type
329
+ ORDER BY total DESC
330
+ FORMAT JSON
331
+ `;
332
+ const result = await queryAnalyticsEngine(sql, accountId, apiToken);
333
+ if (result.error) {
334
+ return c.html(renderMockPerformance(period));
335
+ }
336
+ if (result.data.length === 0) {
337
+ return c.html(renderMockPerformance(period));
338
+ }
339
+ let html = `<table>
340
+ <thead>
341
+ <tr>
342
+ <th>Type</th>
343
+ <th>Count</th>
344
+ <th>Avg Solve</th>
345
+ <th>p50</th>
346
+ <th>p95</th>
347
+ <th>Min</th>
348
+ <th>Max</th>
349
+ <th>Avg Response</th>
350
+ </tr>
351
+ </thead>
352
+ <tbody>`;
353
+ for (const row of result.data) {
354
+ html += `<tr>
355
+ <td><span class="badge badge-info">${escapeHtml(String(row.challenge_type || '-'))}</span></td>
356
+ <td>${formatNumber(Number(row.total || 0))}</td>
357
+ <td>${Math.round(Number(row.avg_solve_ms || 0))}ms</td>
358
+ <td>${Math.round(Number(row.p50_solve_ms || 0))}ms</td>
359
+ <td>${Math.round(Number(row.p95_solve_ms || 0))}ms</td>
360
+ <td>${Math.round(Number(row.min_solve_ms || 0))}ms</td>
361
+ <td>${Math.round(Number(row.max_solve_ms || 0))}ms</td>
362
+ <td>${Math.round(Number(row.avg_response_ms || 0))}ms</td>
363
+ </tr>`;
364
+ }
365
+ html += '</tbody></table>';
366
+ return c.html(`<fieldset>
367
+ <legend>Performance (${period})</legend>
368
+ ${html}
369
+ </fieldset>`);
370
+ }
371
+ /**
372
+ * GET /dashboard/api/errors?period=24h
373
+ * Returns HTML fragment with error breakdown
374
+ */
375
+ export async function handleErrors(c, appId) {
376
+ const period = parsePeriod(c.req.query('period'));
377
+ const interval = periodToInterval(period);
378
+ const accountId = c.env.CF_ACCOUNT_ID;
379
+ const apiToken = c.env.CF_API_TOKEN;
380
+ if (!accountId || !apiToken) {
381
+ return c.html(renderMockErrors(period));
382
+ }
383
+ const sql = `
384
+ SELECT
385
+ blob1 as event_type,
386
+ count() as total
387
+ FROM botcha
388
+ WHERE timestamp >= now() - INTERVAL ${interval}
389
+ AND blob1 IN ('error', 'rate_limit_exceeded', 'auth_failure')
390
+ GROUP BY event_type
391
+ ORDER BY total DESC
392
+ FORMAT JSON
393
+ `;
394
+ const result = await queryAnalyticsEngine(sql, accountId, apiToken);
395
+ if (result.error) {
396
+ return c.html(renderMockErrors(period));
397
+ }
398
+ if (result.data.length === 0) {
399
+ return c.html(`<fieldset>
400
+ <legend>Errors & Rate Limits (${period})</legend>
401
+ <div class="alert alert-success">No errors or rate limits in this period</div>
402
+ </fieldset>`);
403
+ }
404
+ const maxTotal = Math.max(...result.data.map(r => Number(r.total || 0)));
405
+ const items = result.data.map(row => ({
406
+ name: String(row.event_type || 'unknown').replace(/_/g, ' '),
407
+ value: Number(row.total || 0),
408
+ maxValue: maxTotal,
409
+ }));
410
+ return c.html(`<fieldset>
411
+ <legend>Errors & Rate Limits (${period})</legend>
412
+ ${renderBarChart(items)}
413
+ </fieldset>`);
414
+ }
415
+ /**
416
+ * GET /dashboard/api/geo?period=24h
417
+ * Returns HTML fragment with geographic distribution
418
+ */
419
+ export async function handleGeo(c, appId) {
420
+ const period = parsePeriod(c.req.query('period'));
421
+ const interval = periodToInterval(period);
422
+ const accountId = c.env.CF_ACCOUNT_ID;
423
+ const apiToken = c.env.CF_API_TOKEN;
424
+ if (!accountId || !apiToken) {
425
+ return c.html(renderMockGeo(period));
426
+ }
427
+ const sql = `
428
+ SELECT
429
+ blob7 as country,
430
+ count() as total
431
+ FROM botcha
432
+ WHERE timestamp >= now() - INTERVAL ${interval}
433
+ AND blob7 != '' AND blob7 != 'unknown'
434
+ GROUP BY country
435
+ ORDER BY total DESC
436
+ LIMIT 20
437
+ FORMAT JSON
438
+ `;
439
+ const result = await queryAnalyticsEngine(sql, accountId, apiToken);
440
+ if (result.error) {
441
+ return c.html(renderMockGeo(period));
442
+ }
443
+ if (result.data.length === 0) {
444
+ return c.html(renderMockGeo(period));
445
+ }
446
+ const maxTotal = Math.max(...result.data.map(r => Number(r.total || 0)));
447
+ const items = result.data.map(row => ({
448
+ name: String(row.country || '??'),
449
+ value: Number(row.total || 0),
450
+ maxValue: maxTotal,
451
+ }));
452
+ return c.html(`<fieldset>
453
+ <legend>Top Countries (${period})</legend>
454
+ ${renderBarChart(items)}
455
+ </fieldset>`);
456
+ }
457
+ // ============ TIME FORMATTING ============
458
+ function formatTimeBucket(timestamp, period) {
459
+ if (!timestamp)
460
+ return '—';
461
+ try {
462
+ const d = new Date(timestamp);
463
+ if (isNaN(d.getTime()))
464
+ return timestamp.substring(0, 16);
465
+ if (period === '1h' || period === '24h') {
466
+ return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
467
+ }
468
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
469
+ }
470
+ catch {
471
+ return timestamp.substring(0, 16);
472
+ }
473
+ }
474
+ // ============ MOCK DATA (when CF_API_TOKEN not configured) ============
475
+ function renderMockOverview(_period) {
476
+ return `<div class="dashboard-grid">
477
+ ${renderStatCard('1,247', 'Challenges Generated')}
478
+ ${renderStatCard('1,089', 'Verifications')}
479
+ ${renderStatCard('94%', 'Success Rate', 'text-success')}
480
+ ${renderStatCard('127ms', 'Avg Solve Time')}
481
+ ${renderStatCard('3', 'Rate Limits Hit', 'text-warning')}
482
+ ${renderStatCard('0', 'Errors')}
483
+ </div>`;
484
+ }
485
+ function renderMockVolume(_period) {
486
+ const items = [
487
+ { name: '00:00', value: 42, maxValue: 89 },
488
+ { name: '04:00', value: 15, maxValue: 89 },
489
+ { name: '08:00', value: 67, maxValue: 89 },
490
+ { name: '12:00', value: 89, maxValue: 89 },
491
+ { name: '16:00', value: 73, maxValue: 89 },
492
+ { name: '20:00', value: 55, maxValue: 89 },
493
+ ];
494
+ return `<fieldset>
495
+ <legend>Request Volume (sample)</legend>
496
+ ${renderBarChart(items)}
497
+ </fieldset>`;
498
+ }
499
+ function renderMockTypes(_period) {
500
+ const items = [
501
+ { name: 'hybrid (412 ok / 18 fail)', value: 430, maxValue: 430 },
502
+ { name: 'speed (389 ok / 12 fail)', value: 401, maxValue: 430 },
503
+ { name: 'reasoning (256 ok / 45 fail)', value: 301, maxValue: 430 },
504
+ { name: 'standard (22 ok / 5 fail)', value: 27, maxValue: 430 },
505
+ ];
506
+ return `<fieldset>
507
+ <legend>Challenge Types (sample)</legend>
508
+ ${renderBarChart(items)}
509
+ </fieldset>`;
510
+ }
511
+ function renderMockPerformance(_period) {
512
+ return `<fieldset>
513
+ <legend>Performance (sample)</legend>
514
+ <table>
515
+ <thead>
516
+ <tr>
517
+ <th>Type</th><th>Count</th><th>Avg Solve</th><th>p50</th><th>p95</th><th>Min</th><th>Max</th><th>Avg Response</th>
518
+ </tr>
519
+ </thead>
520
+ <tbody>
521
+ <tr><td><span class="badge badge-info">speed</span></td><td>389</td><td>127ms</td><td>112ms</td><td>289ms</td><td>45ms</td><td>498ms</td><td>12ms</td></tr>
522
+ <tr><td><span class="badge badge-info">hybrid</span></td><td>412</td><td>1,845ms</td><td>1,620ms</td><td>4,200ms</td><td>890ms</td><td>8,500ms</td><td>15ms</td></tr>
523
+ <tr><td><span class="badge badge-info">reasoning</span></td><td>256</td><td>4,230ms</td><td>3,800ms</td><td>8,900ms</td><td>1,200ms</td><td>28,000ms</td><td>18ms</td></tr>
524
+ </tbody>
525
+ </table>
526
+ </fieldset>`;
527
+ }
528
+ function renderMockErrors(_period) {
529
+ return `<fieldset>
530
+ <legend>Errors & Rate Limits (sample)</legend>
531
+ <div class="alert alert-success">No errors or rate limits (sample data)</div>
532
+ </fieldset>`;
533
+ }
534
+ function renderMockGeo(_period) {
535
+ const items = [
536
+ { name: 'US', value: 523, maxValue: 523 },
537
+ { name: 'DE', value: 189, maxValue: 523 },
538
+ { name: 'JP', value: 134, maxValue: 523 },
539
+ { name: 'GB', value: 98, maxValue: 523 },
540
+ { name: 'FR', value: 67, maxValue: 523 },
541
+ ];
542
+ return `<fieldset>
543
+ <legend>Top Countries (sample)</legend>
544
+ ${renderBarChart(items)}
545
+ </fieldset>`;
546
+ }