@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.
- package/dist/analytics.d.ts +60 -0
- package/dist/analytics.d.ts.map +1 -0
- package/dist/analytics.js +130 -0
- package/dist/apps.d.ts +159 -0
- package/dist/apps.d.ts.map +1 -0
- package/dist/apps.js +307 -0
- package/dist/auth.d.ts +93 -6
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +251 -9
- package/dist/challenges.d.ts +31 -7
- package/dist/challenges.d.ts.map +1 -1
- package/dist/challenges.js +551 -144
- package/dist/dashboard/api.d.ts +70 -0
- package/dist/dashboard/api.d.ts.map +1 -0
- package/dist/dashboard/api.js +546 -0
- package/dist/dashboard/auth.d.ts +183 -0
- package/dist/dashboard/auth.d.ts.map +1 -0
- package/dist/dashboard/auth.js +401 -0
- package/dist/dashboard/device-code.d.ts +43 -0
- package/dist/dashboard/device-code.d.ts.map +1 -0
- package/dist/dashboard/device-code.js +77 -0
- package/dist/dashboard/index.d.ts +31 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +64 -0
- package/dist/dashboard/layout.d.ts +47 -0
- package/dist/dashboard/layout.d.ts.map +1 -0
- package/dist/dashboard/layout.js +38 -0
- package/dist/dashboard/pages.d.ts +11 -0
- package/dist/dashboard/pages.d.ts.map +1 -0
- package/dist/dashboard/pages.js +18 -0
- package/dist/dashboard/styles.d.ts +11 -0
- package/dist/dashboard/styles.d.ts.map +1 -0
- package/dist/dashboard/styles.js +633 -0
- package/dist/email.d.ts +44 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +119 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +644 -50
- package/dist/rate-limit.d.ts +11 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +13 -2
- package/dist/routes/stream.js +1 -1
- package/dist/static.d.ts +728 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/static.js +818 -0
- 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">>_</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, '&')
|
|
152
|
+
.replace(/</g, '<')
|
|
153
|
+
.replace(/>/g, '>')
|
|
154
|
+
.replace(/"/g, '"');
|
|
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
|
+
}
|