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 + ) && (
Straight-line distance: @@ -99,13 +105,12 @@ export function PathModal({ open, onClose, paths, senderInfo, contacts, config } {!hasSinglePath && (
Path {index + 1}{' '} - — received {formatTime(pathData.received_at)} + + — received {formatTime(pathData.received_at)} +
)} - +
))} @@ -198,7 +203,6 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) { )} - ); } @@ -301,9 +305,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) { {isUnknown ? ( -
- <UNKNOWN> -
+
<UNKNOWN>
) : isAmbiguous ? (
{hop.matches.map((contact) => { diff --git a/frontend/src/hooks/useRepeaterMode.ts b/frontend/src/hooks/useRepeaterMode.ts index 8b7c889..cc40701 100644 --- a/frontend/src/hooks/useRepeaterMode.ts +++ b/frontend/src/hooks/useRepeaterMode.ts @@ -166,12 +166,7 @@ export function useRepeaterMode( 1 ); - const aclMessage = createLocalMessage( - conversationId, - formatAcl(telemetry.acl), - false, - 2 - ); + const aclMessage = createLocalMessage(conversationId, formatAcl(telemetry.acl), false, 2); // Add all messages to the list setMessages((prev) => [...prev, telemetryMessage, neighborsMessage, aclMessage]); @@ -215,12 +210,7 @@ export function useRepeaterMode( if (activeConversationRef.current?.id !== conversationId) return; // Use the actual timestamp from the repeater if available - const responseMessage = createLocalMessage( - conversationId, - response.response, - false, - 1 - ); + const responseMessage = createLocalMessage(conversationId, response.response, false, 1); if (response.sender_timestamp) { responseMessage.sender_timestamp = response.sender_timestamp; } diff --git a/tests/test_api.py b/tests/test_api.py index 4e07c45..d52ca1a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -187,7 +187,10 @@ class TestMessagesEndpoint: with ( patch("app.dependencies.radio_manager") as mock_rm, patch("app.repository.ChannelRepository") as mock_chan_repo, - patch("app.repository.AppSettingsRepository.get", new=AsyncMock(return_value=AppSettings())), + patch( + "app.repository.AppSettingsRepository.get", + new=AsyncMock(return_value=AppSettings()), + ), patch("app.routers.messages.MessageRepository") as mock_msg_repo, ): mock_rm.is_connected = True diff --git a/tests/test_frontend_static.py b/tests/test_frontend_static.py new file mode 100644 index 0000000..be3faf3 --- /dev/null +++ b/tests/test_frontend_static.py @@ -0,0 +1,66 @@ +import logging + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.frontend_static import register_frontend_static_routes + + +def test_missing_dist_logs_error_and_keeps_app_running(tmp_path, caplog): + app = FastAPI() + missing_dist = tmp_path / "frontend" / "dist" + + with caplog.at_level(logging.ERROR): + registered = register_frontend_static_routes(app, missing_dist) + + assert registered is False + assert "Frontend build directory not found" in caplog.text + + with TestClient(app) as client: + # App still runs; no frontend route is registered. + assert client.get("/").status_code == 404 + + +def test_missing_index_logs_error_and_skips_frontend_routes(tmp_path, caplog): + app = FastAPI() + dist_dir = tmp_path / "frontend" / "dist" + dist_dir.mkdir(parents=True) + + with caplog.at_level(logging.ERROR): + registered = register_frontend_static_routes(app, dist_dir) + + assert registered is False + assert "Frontend index file not found" in caplog.text + + +def test_valid_dist_serves_static_and_spa_fallback(tmp_path): + app = FastAPI() + dist_dir = tmp_path / "frontend" / "dist" + assets_dir = dist_dir / "assets" + dist_dir.mkdir(parents=True) + assets_dir.mkdir(parents=True) + + index_file = dist_dir / "index.html" + index_file.write_text("index page") + (dist_dir / "robots.txt").write_text("User-agent: *") + (assets_dir / "app.js").write_text("console.log('ok');") + + registered = register_frontend_static_routes(app, dist_dir) + assert registered is True + + with TestClient(app) as client: + root_response = client.get("/") + assert root_response.status_code == 200 + assert "index page" in root_response.text + + file_response = client.get("/robots.txt") + assert file_response.status_code == 200 + assert file_response.text == "User-agent: *" + + missing_response = client.get("/channel/some-route") + assert missing_response.status_code == 200 + assert "index page" in missing_response.text + + asset_response = client.get("/assets/app.js") + assert asset_response.status_code == 200 + assert "console.log('ok');" in asset_response.text