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

View File

@@ -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)

73
app/frontend_static.py Normal file
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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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))

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) */}
{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
) && (
<div className="text-sm pb-2 border-b border-border">
<span className="text-muted-foreground">Straight-line distance: </span>
<span className="font-medium">
@@ -99,13 +105,12 @@ export function PathModal({ open, onClose, paths, senderInfo, contacts, config }
{!hasSinglePath && (
<div className="text-sm text-foreground/70 font-semibold mb-2 pb-1 border-b border-border">
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>
)}
<PathVisualization
resolved={pathData.resolved}
senderInfo={senderInfo}
/>
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
</div>
))}
</div>
@@ -198,7 +203,6 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
</span>
</div>
)}
</div>
);
}
@@ -301,9 +305,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
</div>
{isUnknown ? (
<div className="font-medium text-muted-foreground/70">
&lt;UNKNOWN&gt;
</div>
<div className="font-medium text-muted-foreground/70">&lt;UNKNOWN&gt;</div>
) : isAmbiguous ? (
<div>
{hop.matches.map((contact) => {

View File

@@ -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;
}

View File

@@ -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

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