diff --git a/AGENTS.md b/AGENTS.md index 0e28099..c5dd2cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,7 +184,7 @@ Terminal 2: `cd frontend && npm run dev` ### Production -In production, the FastAPI backend serves the compiled frontend. You must build the frontend first: +In production, the FastAPI backend serves the compiled frontend. Build the frontend first: ```bash cd frontend && npm install && npm run build && cd .. @@ -193,6 +193,8 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 Access at `http://localhost:8000`. All API routes are prefixed with `/api`. +If `frontend/dist` (or `frontend/dist/index.html`) is missing, backend startup now logs an explicit error and continues serving API routes. In that case, frontend static routes are not mounted until a frontend build is present. + ## Testing ### Backend (pytest) @@ -207,6 +209,7 @@ Key test files: - `tests/test_event_handlers.py` - ACK tracking, repeat detection - `tests/test_api.py` - API endpoints, read state tracking - `tests/test_migrations.py` - Database migration system +- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling) ### Frontend (Vitest) diff --git a/app/frontend_static.py b/app/frontend_static.py new file mode 100644 index 0000000..e7a735c --- /dev/null +++ b/app/frontend_static.py @@ -0,0 +1,73 @@ +import logging +from pathlib import Path + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +logger = logging.getLogger(__name__) + + +def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool: + """Register frontend static file routes if a built frontend is available. + + Returns True when routes are registered, False when frontend files are + missing/incomplete. Missing frontend files are logged but are not fatal. + """ + frontend_dir = frontend_dir.resolve() + index_file = frontend_dir / "index.html" + assets_dir = frontend_dir / "assets" + + if not frontend_dir.exists(): + logger.error( + "Frontend build directory not found at %s. " + "Run 'cd frontend && npm run build'. API will continue without frontend routes.", + frontend_dir, + ) + return False + + if not frontend_dir.is_dir(): + logger.error( + "Frontend build path is not a directory: %s. " + "API will continue without frontend routes.", + frontend_dir, + ) + return False + + if not index_file.exists(): + logger.error( + "Frontend index file not found at %s. " + "Run 'cd frontend && npm run build'. API will continue without frontend routes.", + index_file, + ) + return False + + if assets_dir.exists() and assets_dir.is_dir(): + app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") + else: + logger.warning( + "Frontend assets directory missing at %s; /assets files will not be served", + assets_dir, + ) + + @app.get("/") + async def serve_index(): + """Serve the frontend index.html.""" + return FileResponse(index_file) + + @app.get("/{path:path}") + async def serve_frontend(path: str): + """Serve frontend files, falling back to index.html for SPA routing.""" + file_path = (frontend_dir / path).resolve() + try: + file_path.relative_to(frontend_dir) + except ValueError: + raise HTTPException(status_code=404, detail="Not found") from None + + if file_path.exists() and file_path.is_file(): + return FileResponse(file_path) + + return FileResponse(index_file) + + logger.info("Serving frontend from %s", frontend_dir) + return True diff --git a/app/main.py b/app/main.py index c989330..aa82b62 100644 --- a/app/main.py +++ b/app/main.py @@ -2,13 +2,12 @@ import logging from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse -from fastapi.staticfiles import StaticFiles from app.config import setup_logging from app.database import db +from app.frontend_static import register_frontend_static_routes from app.radio import radio_manager from app.radio_sync import ( stop_message_polling, @@ -88,27 +87,4 @@ app.include_router(ws.router, prefix="/api") # Serve frontend static files in production FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist" - -if FRONTEND_DIR.exists(): - # Serve static assets (JS, CSS, etc.) - app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets") - - # Serve other static files from frontend/dist (like wordlist) - @app.get("/{path:path}") - async def serve_frontend(path: str): - """Serve frontend files, falling back to index.html for SPA routing.""" - base_dir = FRONTEND_DIR.resolve() - file_path = (FRONTEND_DIR / path).resolve() - try: - file_path.relative_to(base_dir) - except ValueError: - raise HTTPException(status_code=404, detail="Not found") from None - if file_path.exists() and file_path.is_file(): - return FileResponse(file_path) - # Fall back to index.html for SPA routing - return FileResponse(base_dir / "index.html") - - @app.get("/") - async def serve_index(): - """Serve the frontend index.html.""" - return FileResponse(FRONTEND_DIR / "index.html") +register_frontend_static_routes(app, FRONTEND_DIR) diff --git a/app/radio_sync.py b/app/radio_sync.py index 91445ae..08e3945 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -598,7 +598,9 @@ async def sync_recent_contacts_to_radio(force: bool = False) -> dict: break if len(selected_contacts) < max_contacts: - recent_contacts = await ContactRepository.get_recent_non_repeaters(limit=max_contacts) + recent_contacts = await ContactRepository.get_recent_non_repeaters( + limit=max_contacts + ) for contact in recent_contacts: key = contact.public_key.lower() if key in selected_keys: diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 16b1ba3..80057dc 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -307,13 +307,17 @@ async def request_telemetry(public_key: str, request: TelemetryRequest) -> Telem status = None for attempt in range(1, 4): logger.debug("Status request attempt %d/3", attempt) - status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5) + status = await mc.commands.req_status_sync( + contact.public_key, timeout=10, min_timeout=5 + ) if status: break logger.debug("Status request timeout, retrying...") if not status: - raise HTTPException(status_code=504, detail="No response from repeater after 3 attempts") + raise HTTPException( + status_code=504, detail="No response from repeater after 3 attempts" + ) logger.info("Received telemetry from %s: %s", contact.public_key[:12], status) @@ -482,7 +486,9 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com send_result = await mc.commands.send_cmd(contact.public_key, request.command) if send_result.type == EventType.ERROR: - raise HTTPException(status_code=500, detail=f"Failed to send command: {send_result.payload}") + raise HTTPException( + status_code=500, detail=f"Failed to send command: {send_result.payload}" + ) # Wait for response (MESSAGES_WAITING event, then get_msg) try: @@ -503,7 +509,9 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com response_event = await mc.commands.get_msg() if response_event.type == EventType.ERROR: - return CommandResponse(command=request.command, response=f"(error: {response_event.payload})") + return CommandResponse( + command=request.command, response=f"(error: {response_event.payload})" + ) # Extract the response text and timestamp from the payload response_text = response_event.payload.get("text", str(response_event.payload)) diff --git a/frontend/src/components/PathModal.tsx b/frontend/src/components/PathModal.tsx index d3120c1..fbce3f9 100644 --- a/frontend/src/components/PathModal.tsx +++ b/frontend/src/components/PathModal.tsx @@ -77,8 +77,14 @@ export function PathModal({ open, onClose, paths, senderInfo, contacts, config } {/* Straight-line distance (sender to receiver, same for all routes) */} {resolvedPaths.length > 0 && - isValidLocation(resolvedPaths[0].resolved.sender.lat, resolvedPaths[0].resolved.sender.lon) && - isValidLocation(resolvedPaths[0].resolved.receiver.lat, resolvedPaths[0].resolved.receiver.lon) && ( + isValidLocation( + resolvedPaths[0].resolved.sender.lat, + resolvedPaths[0].resolved.sender.lon + ) && + isValidLocation( + resolvedPaths[0].resolved.receiver.lat, + resolvedPaths[0].resolved.receiver.lon + ) && (