velxio/backend/app/main.py

142 lines
5.6 KiB
Python
Raw Normal View History

import logging
import sys
import asyncio
from contextlib import asynccontextmanager
logging.basicConfig(level=logging.INFO, format='%(levelname)s %(name)s: %(message)s')
# On Windows, asyncio defaults to SelectorEventLoop which does NOT support
# create_subprocess_exec (raises NotImplementedError). Force ProactorEventLoop.
if sys.platform == 'win32':
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from app.api.routes import compile, compile_chip, libraries
from app.api.routes.admin import router as admin_router
from app.api.routes.auth import router as auth_router
2026-04-26 05:46:52 +07:00
from app.api.routes.metrics import router as metrics_router
from app.api.routes.projects import router as projects_router
from app.core.config import settings
from app.database.session import Base, async_engine
# Import models so SQLAlchemy registers them before create_all
import app.models.user # noqa: F401
import app.models.project # noqa: F401
2026-04-26 05:46:52 +07:00
import app.models.usage_event # noqa: F401
logger = logging.getLogger(__name__)
def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -> None:
"""Prevent unhandled asyncio task exceptions from killing the uvicorn process.
Normally uvicorn re-raises unhandled task exceptions at the event-loop level,
which can crash the whole process. The main culprit is a race condition in
websockets <12.0 (legacy/protocol.py AssertionError during keepalive ping).
Upgrading websockets>=12.0 is the primary fix; this handler is a safety net.
"""
exc = context.get("exception")
msg = context.get("message", "")
if exc is not None:
logger.error("Unhandled asyncio task exception (swallowed): %s%r", msg, exc)
else:
# No exception object — let default handler deal with it
loop.default_exception_handler(context)
@asynccontextmanager
async def lifespan(_app: FastAPI):
asyncio.get_event_loop().set_exception_handler(_asyncio_exception_handler)
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
2026-04-26 05:46:52 +07:00
# Lightweight auto-migrations for legacy DBs. Each statement is wrapped
# in try/except so re-runs after the column already exists are no-ops.
legacy_migrations = [
"ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0",
# Phase: usage metrics
"ALTER TABLE users ADD COLUMN total_compiles INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN total_compile_errors INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN total_runs INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN last_active_at DATETIME",
"ALTER TABLE projects ADD COLUMN compile_count INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE projects ADD COLUMN compile_error_count INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE projects ADD COLUMN run_count INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE projects ADD COLUMN update_count INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE projects ADD COLUMN last_compiled_at DATETIME",
"ALTER TABLE projects ADD COLUMN last_run_at DATETIME",
# Country tracking (CF-IPCountry)
"ALTER TABLE users ADD COLUMN signup_country VARCHAR(2)",
"ALTER TABLE users ADD COLUMN last_country VARCHAR(2)",
"ALTER TABLE usage_events ADD COLUMN country VARCHAR(2)",
feat: persist multi-board projects + add auto-save The project save/load pipeline only persisted a single `board_type`, so multi-board workspaces silently lost every board except the active one on save, and wires referencing the dropped boards' IDs orphaned to the canvas corner on reload. An audit of the production backup found 74/306 projects (24%) with at least one orphaned wire and 174/301 non-trivial projects whose code was still the default Blink template — strong signal that users save once and never re-save. Backend - Add `boards_json` column on `projects` with idempotent ALTER TABLE in the lifespan migration list. - New `FileGroup` schema + `file_groups` array on ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat. - `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via `read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on read; legacy single-list `files` only updates the active group, leaving other boards' files intact. - `_persist_files_from_body` honors file_groups → files → code priority. Frontend - `useSimulatorStore.addBoard` accepts an optional `explicitId` so saved board IDs can be restored verbatim (wires reference IDs literally). - New `loadProjectState({boards, fileGroups, components, wires, activeBoardId})` action: tears down current boards, recreates from the payload, restores file groups atomically, recalculates wire positions on the next frame, and refreshes the Interconnect. - `useEditorStore.replaceFileGroups` for atomic multi-group restore. - `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through `buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects by synthesising a default board from `board_type`). Auto-save (#useAutoSaveProject hook) - 2.5s debounced silent PUT triggered ONLY when an authenticated user has a `currentProject` with a UUID. State hash detects real changes vs. UI-only churn; baseline is reset on project load so the just-loaded state isn't immediately re-saved. - `beforeunload` flush via `fetch keepalive: true` (supports PUT + credentials, survives unload). - Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error). Backfill script (one-off, idempotent) - `backend/scripts/backfill_boards_2026_05.py` populates `boards_json` for legacy projects. Heuristic per project, based on which board IDs the wires reference: Case A — wires only ref 'arduino-uno' but board_type ≠ uno: rename id→board_type and rewrite wire endpoints. Case B — single-board normal: keep verbatim. Case C — multi-board: recreate one board per distinct ref, infer kind by stripping trailing -N suffix. Also moves any flat files into the active board's group subdir. Stdlib-only, runs from host or `docker exec`. Docker - `Dockerfile.standalone` now copies `backend/scripts/` into the image so the backfill is callable via `docker exec velxio-app python /app/scripts/backfill_boards_2026_05.py --apply`. Verified locally on the restored production backup (363 projects): 33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans. Re-running the script after apply skips all 363 (idempotent). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
# Multi-board persistence (replaces single board_type as the source of truth)
"ALTER TABLE projects ADD COLUMN boards_json TEXT NOT NULL DEFAULT '[]'",
2026-04-26 05:46:52 +07:00
]
for stmt in legacy_migrations:
try:
await conn.execute(text(stmt))
except Exception:
pass # Column already exists
yield
app = FastAPI(
title="Arduino Emulator API",
description="Compilation and project management API",
version="1.0.0",
lifespan=lifespan,
# Moved from /docs to /api/docs so the frontend /docs/* documentation
# routes are served by the React SPA without any nginx conflict.
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
# CORS for local development
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://localhost:5174",
"http://localhost:5175",
settings.FRONTEND_URL,
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(compile.router, prefix="/api/compile", tags=["compilation"])
app.include_router(compile_chip.router, prefix="/api/compile-chip", tags=["custom-chips"])
app.include_router(libraries.router, prefix="/api/libraries", tags=["libraries"])
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(projects_router, prefix="/api", tags=["projects"])
2026-04-26 05:46:52 +07:00
app.include_router(metrics_router, prefix="/api/metrics", tags=["metrics"])
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
# WebSockets
from app.api.routes import simulation
app.include_router(simulation.router, prefix="/api/simulation", tags=["simulation"])
# IoT Gateway — HTTP proxy for ESP32 web servers
from app.api.routes import iot_gateway
app.include_router(iot_gateway.router, prefix="/api/gateway", tags=["iot-gateway"])
@app.get("/")
def root():
return {
"message": "Arduino Emulator API",
"version": "1.0.0",
"docs": "/api/docs",
}
@app.get("/health")
def health_check():
return {"status": "healthy"}