2026-03-03 10:20:49 +07:00
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
|
2026-03-07 23:55:11 +07:00
|
|
|
|
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
2026-03-03 10:20:49 +07:00
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
|
export interface SketchFile {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
content: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 10:20:49 +07:00
|
|
|
|
export interface CompileResult {
|
|
|
|
|
|
success: boolean;
|
|
|
|
|
|
hex_content?: string;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
binary_content?: string; // base64-encoded .bin for RP2040
|
2026-03-05 05:28:33 +07:00
|
|
|
|
binary_type?: 'bin' | 'uf2';
|
2026-04-22 02:45:45 +07:00
|
|
|
|
has_wifi?: boolean; // True when sketch uses WiFi (ESP32 only)
|
2026-03-03 10:20:49 +07:00
|
|
|
|
stdout: string;
|
|
|
|
|
|
stderr: string;
|
|
|
|
|
|
error?: string;
|
2026-03-06 20:14:50 +07:00
|
|
|
|
core_install_log?: string;
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:22:09 +07:00
|
|
|
|
interface CompileStartResponse {
|
|
|
|
|
|
job_id: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CompileStatusResponse {
|
|
|
|
|
|
state: 'pending' | 'running' | 'done' | 'error';
|
|
|
|
|
|
started_at: number;
|
|
|
|
|
|
finished_at: number | null;
|
|
|
|
|
|
result: CompileResult | null;
|
|
|
|
|
|
error: string | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const POLL_INTERVAL_MS = 2000;
|
|
|
|
|
|
const MAX_POLL_DURATION_MS = 15 * 60 * 1000; // 15 minutes — covers cold ESP-IDF builds
|
|
|
|
|
|
|
|
|
|
|
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Compile a sketch via the async job pipeline.
|
|
|
|
|
|
*
|
|
|
|
|
|
* POST /compile/start → { job_id }
|
|
|
|
|
|
* GET /compile/status/<job_id> (×N) → { state, result?, error? }
|
|
|
|
|
|
*
|
|
|
|
|
|
* Each individual request returns in milliseconds, so Cloudflare's 100s edge
|
|
|
|
|
|
* timeout never kicks in — even when the underlying ESP-IDF cold build runs
|
|
|
|
|
|
* for 5-7 minutes. Falls back to throwing an Error after MAX_POLL_DURATION_MS.
|
|
|
|
|
|
*/
|
2026-03-03 10:20:49 +07:00
|
|
|
|
export async function compileCode(
|
2026-03-06 20:14:50 +07:00
|
|
|
|
files: SketchFile[],
|
2026-04-22 02:45:45 +07:00
|
|
|
|
board: string = 'arduino:avr:uno',
|
feat(metrics): add usage analytics dashboard with country tracking
Track per-user, per-project, and per-board usage to inform pricing tier
decisions. Adds an admin dashboard with KPIs (DAU/WAU/MAU, totals,
success rate), time-series charts for compiles/runs, board family +
FQBN breakdowns, "board diversity" pie chart (key freemium signal),
top users/projects, and per-country breakdown via Cloudflare's
CF-IPCountry header. Admin can now also view private projects.
Backend:
- New UsageEvent table (append-only event log with user_id, project_id,
event_type, board_fqbn/family, country, error_kind, duration_ms)
- Aggregate counters on User (total_compiles/runs/errors, last_active,
signup_country, last_country) and Project (compile/run/update counts,
last_compiled/run timestamps) kept in sync by MetricsService for O(1)
dashboard reads
- 10 admin endpoints under /api/admin/metrics/{overview, timeseries,
boards, board-diversity, top-users, top-projects, countries,
users/{id}, projects/{id}}
- POST /api/metrics/run for client-side run telemetry
- Country detection via cf-ipcountry header (no DB / no API calls)
- Auto-migrations in lifespan for legacy DBs
Frontend:
- recharts-powered Dashboard tab with KPI cards and 4 charts
- New Boards tab with per-family + per-FQBN breakdown
- Country column with flag emoji on Users tab
- Top countries card on Dashboard
- compileCode now forwards project_id; Run button reports via WS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:46:52 +07:00
|
|
|
|
projectId?: string | null,
|
2026-03-03 10:20:49 +07:00
|
|
|
|
): Promise<CompileResult> {
|
2026-05-09 10:22:09 +07:00
|
|
|
|
console.log('Sending compilation request to:', `${API_BASE}/compile/start`);
|
|
|
|
|
|
console.log('Board:', board);
|
|
|
|
|
|
console.log(
|
|
|
|
|
|
'Files:',
|
|
|
|
|
|
files.map((f) => f.name),
|
|
|
|
|
|
);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
|
2026-05-09 10:22:09 +07:00
|
|
|
|
let jobId: string;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const startResp = await axios.post<CompileStartResponse>(
|
|
|
|
|
|
`${API_BASE}/compile/start`,
|
feat(metrics): add usage analytics dashboard with country tracking
Track per-user, per-project, and per-board usage to inform pricing tier
decisions. Adds an admin dashboard with KPIs (DAU/WAU/MAU, totals,
success rate), time-series charts for compiles/runs, board family +
FQBN breakdowns, "board diversity" pie chart (key freemium signal),
top users/projects, and per-country breakdown via Cloudflare's
CF-IPCountry header. Admin can now also view private projects.
Backend:
- New UsageEvent table (append-only event log with user_id, project_id,
event_type, board_fqbn/family, country, error_kind, duration_ms)
- Aggregate counters on User (total_compiles/runs/errors, last_active,
signup_country, last_country) and Project (compile/run/update counts,
last_compiled/run timestamps) kept in sync by MetricsService for O(1)
dashboard reads
- 10 admin endpoints under /api/admin/metrics/{overview, timeseries,
boards, board-diversity, top-users, top-projects, countries,
users/{id}, projects/{id}}
- POST /api/metrics/run for client-side run telemetry
- Country detection via cf-ipcountry header (no DB / no API calls)
- Auto-migrations in lifespan for legacy DBs
Frontend:
- recharts-powered Dashboard tab with KPI cards and 4 charts
- New Boards tab with per-family + per-FQBN breakdown
- Country column with flag emoji on Users tab
- Top countries card on Dashboard
- compileCode now forwards project_id; Run button reports via WS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:46:52 +07:00
|
|
|
|
{ files, board_fqbn: board, project_id: projectId ?? null },
|
2026-05-09 10:22:09 +07:00
|
|
|
|
{ withCredentials: true, timeout: 30000 },
|
2026-03-06 20:14:50 +07:00
|
|
|
|
);
|
2026-05-09 10:22:09 +07:00
|
|
|
|
jobId = startResp.data.job_id;
|
|
|
|
|
|
console.log('[compile] queued job', jobId);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Compilation request failed:', error);
|
2026-05-09 10:22:09 +07:00
|
|
|
|
if (axios.isAxiosError(error) && error.response) {
|
|
|
|
|
|
// Server returned a structured error (422, 500, etc.) — surface as a
|
|
|
|
|
|
// failed CompileResult so the editor can show stderr/error.
|
|
|
|
|
|
return error.response.data as CompileResult;
|
|
|
|
|
|
}
|
|
|
|
|
|
throw error instanceof Error
|
|
|
|
|
|
? error
|
|
|
|
|
|
: new Error('No response from server. Is the backend running?');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const startedAt = Date.now();
|
|
|
|
|
|
// Initial small delay so we don't hit /status before the background task
|
|
|
|
|
|
// has even moved past 'pending'.
|
|
|
|
|
|
await sleep(500);
|
|
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
|
if (Date.now() - startedAt > MAX_POLL_DURATION_MS) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Compile timed out client-side after ${Math.round(MAX_POLL_DURATION_MS / 1000)}s`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-03 10:20:49 +07:00
|
|
|
|
|
2026-05-09 10:22:09 +07:00
|
|
|
|
let status: CompileStatusResponse;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const resp = await axios.get<CompileStatusResponse>(
|
|
|
|
|
|
`${API_BASE}/compile/status/${jobId}`,
|
|
|
|
|
|
{ withCredentials: true, timeout: 30000 },
|
|
|
|
|
|
);
|
|
|
|
|
|
status = resp.data;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// Transient poll error — log, wait, retry. Only abort on 404 (job
|
|
|
|
|
|
// expired or never existed).
|
|
|
|
|
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
|
|
|
|
|
throw new Error(`Compile job ${jobId} not found (server may have restarted)`);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}
|
2026-05-09 10:22:09 +07:00
|
|
|
|
console.warn('[compile] status poll error, retrying:', error);
|
|
|
|
|
|
await sleep(POLL_INTERVAL_MS);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (status.state === 'done' && status.result) {
|
|
|
|
|
|
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
|
|
|
|
|
console.log(`[compile] job ${jobId} done in ${elapsed}s`);
|
|
|
|
|
|
return status.result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (status.state === 'error') {
|
|
|
|
|
|
console.error(`[compile] job ${jobId} errored:`, status.error);
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
stdout: '',
|
|
|
|
|
|
stderr: '',
|
|
|
|
|
|
error: status.error || 'Compile failed',
|
|
|
|
|
|
};
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:22:09 +07:00
|
|
|
|
await sleep(POLL_INTERVAL_MS);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|