mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-11 19:23:03 +02: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
|
### 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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"><UNKNOWN></div>
|
||||||
<UNKNOWN>
|
|
||||||
</div>
|
|
||||||
) : isAmbiguous ? (
|
) : isAmbiguous ? (
|
||||||
<div>
|
<div>
|
||||||
{hop.matches.map((contact) => {
|
{hop.matches.map((contact) => {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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