mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add better no-frontend-dir handling (and lint, whoops)
This commit is contained in:
@@ -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
73
app/frontend_static.py
Normal 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
|
||||
30
app/main.py
30
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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">
|
||||
<UNKNOWN>
|
||||
</div>
|
||||
<div className="font-medium text-muted-foreground/70"><UNKNOWN></div>
|
||||
) : isAmbiguous ? (
|
||||
<div>
|
||||
{hop.matches.map((contact) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
66
tests/test_frontend_static.py
Normal file
66
tests/test_frontend_static.py
Normal 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
|
||||
Reference in New Issue
Block a user