Add better no-frontend-dir handling (and lint, whoops)

This commit is contained in:
Jack Kingsman
2026-02-10 20:41:56 -08:00
parent a157390fb7
commit fa37ff6286
9 changed files with 180 additions and 57 deletions
+4 -1
View File
@@ -184,7 +184,7 @@ Terminal 2: `cd frontend && npm run dev`
### Production ### 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 ```bash
cd frontend && npm install && npm run build && cd .. 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`. 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 ## Testing
### Backend (pytest) ### Backend (pytest)
@@ -207,6 +209,7 @@ Key test files:
- `tests/test_event_handlers.py` - ACK tracking, repeat detection - `tests/test_event_handlers.py` - ACK tracking, repeat detection
- `tests/test_api.py` - API endpoints, read state tracking - `tests/test_api.py` - API endpoints, read state tracking
- `tests/test_migrations.py` - Database migration system - `tests/test_migrations.py` - Database migration system
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
### Frontend (Vitest) ### Frontend (Vitest)
+73
View File
@@ -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
+3 -27
View File
@@ -2,13 +2,12 @@ import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.config import setup_logging from app.config import setup_logging
from app.database import db from app.database import db
from app.frontend_static import register_frontend_static_routes
from app.radio import radio_manager from app.radio import radio_manager
from app.radio_sync import ( from app.radio_sync import (
stop_message_polling, stop_message_polling,
@@ -88,27 +87,4 @@ app.include_router(ws.router, prefix="/api")
# Serve frontend static files in production # Serve frontend static files in production
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist" FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
register_frontend_static_routes(app, FRONTEND_DIR)
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")
+3 -1
View File
@@ -598,7 +598,9 @@ async def sync_recent_contacts_to_radio(force: bool = False) -> dict:
break break
if len(selected_contacts) < max_contacts: 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: for contact in recent_contacts:
key = contact.public_key.lower() key = contact.public_key.lower()
if key in selected_keys: if key in selected_keys:
+12 -4
View File
@@ -307,13 +307,17 @@ async def request_telemetry(public_key: str, request: TelemetryRequest) -> Telem
status = None status = None
for attempt in range(1, 4): for attempt in range(1, 4):
logger.debug("Status request attempt %d/3", attempt) 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: if status:
break break
logger.debug("Status request timeout, retrying...") logger.debug("Status request timeout, retrying...")
if not status: 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) 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) send_result = await mc.commands.send_cmd(contact.public_key, request.command)
if send_result.type == EventType.ERROR: 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) # Wait for response (MESSAGES_WAITING event, then get_msg)
try: try:
@@ -503,7 +509,9 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com
response_event = await mc.commands.get_msg() response_event = await mc.commands.get_msg()
if response_event.type == EventType.ERROR: 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 # Extract the response text and timestamp from the payload
response_text = response_event.payload.get("text", str(response_event.payload)) response_text = response_event.payload.get("text", str(response_event.payload))
+13 -11
View File
@@ -77,8 +77,14 @@ export function PathModal({ open, onClose, paths, senderInfo, contacts, config }
{/* Straight-line distance (sender to receiver, same for all routes) */} {/* Straight-line distance (sender to receiver, same for all routes) */}
{resolvedPaths.length > 0 && {resolvedPaths.length > 0 &&
isValidLocation(resolvedPaths[0].resolved.sender.lat, resolvedPaths[0].resolved.sender.lon) && isValidLocation(
isValidLocation(resolvedPaths[0].resolved.receiver.lat, resolvedPaths[0].resolved.receiver.lon) && ( resolvedPaths[0].resolved.sender.lat,
resolvedPaths[0].resolved.sender.lon
) &&
isValidLocation(
resolvedPaths[0].resolved.receiver.lat,
resolvedPaths[0].resolved.receiver.lon
) && (
<div className="text-sm pb-2 border-b border-border"> <div className="text-sm pb-2 border-b border-border">
<span className="text-muted-foreground">Straight-line distance: </span> <span className="text-muted-foreground">Straight-line distance: </span>
<span className="font-medium"> <span className="font-medium">
@@ -99,13 +105,12 @@ export function PathModal({ open, onClose, paths, senderInfo, contacts, config }
{!hasSinglePath && ( {!hasSinglePath && (
<div className="text-sm text-foreground/70 font-semibold mb-2 pb-1 border-b border-border"> <div className="text-sm text-foreground/70 font-semibold mb-2 pb-1 border-b border-border">
Path {index + 1}{' '} Path {index + 1}{' '}
<span className="font-normal text-muted-foreground"> received {formatTime(pathData.received_at)}</span> <span className="font-normal text-muted-foreground">
received {formatTime(pathData.received_at)}
</span>
</div> </div>
)} )}
<PathVisualization <PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
resolved={pathData.resolved}
senderInfo={senderInfo}
/>
</div> </div>
))} ))}
</div> </div>
@@ -198,7 +203,6 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
</span> </span>
</div> </div>
)} )}
</div> </div>
); );
} }
@@ -301,9 +305,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
</div> </div>
{isUnknown ? ( {isUnknown ? (
<div className="font-medium text-muted-foreground/70"> <div className="font-medium text-muted-foreground/70">&lt;UNKNOWN&gt;</div>
&lt;UNKNOWN&gt;
</div>
) : isAmbiguous ? ( ) : isAmbiguous ? (
<div> <div>
{hop.matches.map((contact) => { {hop.matches.map((contact) => {
+2 -12
View File
@@ -166,12 +166,7 @@ export function useRepeaterMode(
1 1
); );
const aclMessage = createLocalMessage( const aclMessage = createLocalMessage(conversationId, formatAcl(telemetry.acl), false, 2);
conversationId,
formatAcl(telemetry.acl),
false,
2
);
// Add all messages to the list // Add all messages to the list
setMessages((prev) => [...prev, telemetryMessage, neighborsMessage, aclMessage]); setMessages((prev) => [...prev, telemetryMessage, neighborsMessage, aclMessage]);
@@ -215,12 +210,7 @@ export function useRepeaterMode(
if (activeConversationRef.current?.id !== conversationId) return; if (activeConversationRef.current?.id !== conversationId) return;
// Use the actual timestamp from the repeater if available // Use the actual timestamp from the repeater if available
const responseMessage = createLocalMessage( const responseMessage = createLocalMessage(conversationId, response.response, false, 1);
conversationId,
response.response,
false,
1
);
if (response.sender_timestamp) { if (response.sender_timestamp) {
responseMessage.sender_timestamp = response.sender_timestamp; responseMessage.sender_timestamp = response.sender_timestamp;
} }
+4 -1
View File
@@ -187,7 +187,10 @@ class TestMessagesEndpoint:
with ( with (
patch("app.dependencies.radio_manager") as mock_rm, patch("app.dependencies.radio_manager") as mock_rm,
patch("app.repository.ChannelRepository") as mock_chan_repo, 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, patch("app.routers.messages.MessageRepository") as mock_msg_repo,
): ):
mock_rm.is_connected = True mock_rm.is_connected = True
+66
View File
@@ -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("<html><body>index page</body></html>")
(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