Add prebuilt frontend
@@ -25,10 +25,11 @@ If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- Node.js LTS or current (20, 22, 24, 25)
|
||||
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
- MeshCore radio connected via USB serial, TCP, or BLE
|
||||
|
||||
Node.js is not required to run the app when using the bundled prebuilt frontend. You only need Node>=20 + recent NPM if you want to develop the frontend or rebuild the web UI from source.
|
||||
|
||||
<details>
|
||||
<summary>Finding your serial port</summary>
|
||||
|
||||
@@ -76,9 +77,6 @@ cd Remote-Terminal-for-MeshCore
|
||||
# Install backend dependencies
|
||||
uv sync
|
||||
|
||||
# Build frontend
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
|
||||
# Run server
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
@@ -101,16 +99,16 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Access at http://localhost:8000
|
||||
Access at http://localhost:8000.
|
||||
|
||||
> **Note:** WebGPU cracking requires HTTPS when not on localhost. See the HTTPS section under Additional Setup.
|
||||
>
|
||||
> If you're running from a source checkout and want to rebuild the UI yourself, install Node.js and run `cd frontend && npm install && npm run build`. The backend prefers `frontend/dist` when present and otherwise falls back to the bundled prebuilt frontend.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
> **Warning:** Docker has intermittent issues with serial event subscriptions. The native method above is more reliable.
|
||||
|
||||
> **Note:** BLE-in-docker is outside the scope of this README, but the env vars should all still work.
|
||||
|
||||
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
|
||||
|
||||
```bash
|
||||
@@ -177,7 +175,7 @@ uv run uvicorn app.main:app --reload
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Dev server at http://localhost:5173 (proxies API to :8000)
|
||||
npm run build # Production build to dist/
|
||||
npm run build # Production build to dist/ (used preferentially) and prebuilt/ (bundled to save low-memory friends from needing to build)
|
||||
```
|
||||
|
||||
Run both the backend and `npm run dev` for hot-reloading frontend development.
|
||||
@@ -207,7 +205,7 @@ cd frontend
|
||||
npm run lint:fix # esLint + auto-fix
|
||||
npm run test:run # run tests
|
||||
npm run format # prettier (always writes)
|
||||
npm run build # build the frontend
|
||||
npm run build # build frontend/dist and frontend/prebuilt
|
||||
```
|
||||
</details>
|
||||
|
||||
@@ -298,11 +296,6 @@ cd /opt/remoteterm
|
||||
sudo -u remoteterm uv venv
|
||||
sudo -u remoteterm uv sync
|
||||
|
||||
# Build frontend (required for the backend to serve the web UI)
|
||||
cd /opt/remoteterm/frontend
|
||||
sudo -u remoteterm npm install
|
||||
sudo -u remoteterm npm run build
|
||||
|
||||
# Install and start service
|
||||
sudo cp /opt/remoteterm/remoteterm.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
@@ -314,6 +307,8 @@ sudo journalctl -u remoteterm -f
|
||||
```
|
||||
|
||||
Edit `/etc/systemd/system/remoteterm.service` to set `MESHCORE_SERIAL_PORT` if needed.
|
||||
|
||||
If you are deploying from a source checkout that does not include the bundled prebuilt frontend, install Node.js and run `cd /opt/remoteterm/frontend && sudo -u remoteterm npm install && sudo -u remoteterm npm run build` before starting the service.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -10,6 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
INDEX_CACHE_CONTROL = "no-store"
|
||||
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
|
||||
STATIC_FILE_CACHE_CONTROL = "public, max-age=3600"
|
||||
FRONTEND_BUILD_INSTRUCTIONS = "Run 'cd frontend && npm install && npm run build'."
|
||||
|
||||
|
||||
class CacheControlStaticFiles(StaticFiles):
|
||||
@@ -48,40 +49,38 @@ def _resolve_request_origin(request: Request) -> str:
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]:
|
||||
"""Resolve and validate a built frontend directory."""
|
||||
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 log_failures:
|
||||
logger.error("Frontend build directory not found at %s.", frontend_dir)
|
||||
return False, frontend_dir
|
||||
|
||||
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 log_failures:
|
||||
logger.error("Frontend build path is not a directory: %s.", frontend_dir)
|
||||
return False, frontend_dir
|
||||
|
||||
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,
|
||||
)
|
||||
if log_failures:
|
||||
logger.error("Frontend index file not found at %s.", index_file)
|
||||
return False, frontend_dir
|
||||
|
||||
return True, frontend_dir
|
||||
|
||||
|
||||
def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
"""Register frontend static file routes if a built frontend is available."""
|
||||
valid, frontend_dir = _validate_frontend_dir(frontend_dir)
|
||||
if not valid:
|
||||
return False
|
||||
|
||||
index_file = frontend_dir / "index.html"
|
||||
assets_dir = frontend_dir / "assets"
|
||||
|
||||
if assets_dir.exists() and assets_dir.is_dir():
|
||||
app.mount(
|
||||
"/assets",
|
||||
@@ -157,6 +156,30 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def register_first_available_frontend_static_routes(
|
||||
app: FastAPI, frontend_dirs: list[Path]
|
||||
) -> Path | None:
|
||||
"""Register frontend routes from the first valid build directory."""
|
||||
for i, candidate in enumerate(frontend_dirs):
|
||||
valid, resolved_candidate = _validate_frontend_dir(candidate, log_failures=False)
|
||||
if not valid:
|
||||
continue
|
||||
|
||||
if register_frontend_static_routes(app, resolved_candidate):
|
||||
logger.info("Selected frontend build directory %s", resolved_candidate)
|
||||
return resolved_candidate
|
||||
|
||||
if i < len(frontend_dirs) - 1:
|
||||
logger.warning("Frontend build at %s was unusable; trying fallback", resolved_candidate)
|
||||
|
||||
logger.error(
|
||||
"No usable frontend build found. Searched: %s. %s API will continue without frontend routes.",
|
||||
", ".join(str(path.resolve()) for path in frontend_dirs),
|
||||
FRONTEND_BUILD_INSTRUCTIONS,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def register_frontend_missing_fallback(app: FastAPI) -> None:
|
||||
"""Register a fallback route that tells the user to build the frontend."""
|
||||
|
||||
@@ -164,7 +187,5 @@ def register_frontend_missing_fallback(app: FastAPI) -> None:
|
||||
async def frontend_not_built():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"detail": "Frontend not built. Run: cd frontend && npm install && npm run build"
|
||||
},
|
||||
content={"detail": f"Frontend not built. {FRONTEND_BUILD_INSTRUCTIONS}"},
|
||||
)
|
||||
|
||||
@@ -11,7 +11,10 @@ from fastapi.responses import JSONResponse
|
||||
from app.config import settings as server_settings
|
||||
from app.config import setup_logging
|
||||
from app.database import db
|
||||
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
|
||||
from app.frontend_static import (
|
||||
register_first_available_frontend_static_routes,
|
||||
register_frontend_missing_fallback,
|
||||
)
|
||||
from app.radio import RadioDisconnectedError
|
||||
from app.radio_sync import (
|
||||
stop_message_polling,
|
||||
@@ -151,6 +154,9 @@ app.include_router(statistics.router, prefix="/api")
|
||||
app.include_router(ws.router, prefix="/api")
|
||||
|
||||
# Serve frontend static files in production
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
if not register_frontend_static_routes(app, FRONTEND_DIR):
|
||||
FRONTEND_DIST_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
FRONTEND_PREBUILT_DIR = Path(__file__).parent.parent / "frontend" / "prebuilt"
|
||||
if not register_first_available_frontend_static_routes(
|
||||
app, [FRONTEND_DIST_DIR, FRONTEND_PREBUILT_DIR]
|
||||
):
|
||||
register_frontend_missing_fallback(app)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "tsc && vite build && vite build --outDir prebuilt",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -5,8 +5,10 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.frontend_static import (
|
||||
ASSET_CACHE_CONTROL,
|
||||
FRONTEND_BUILD_INSTRUCTIONS,
|
||||
INDEX_CACHE_CONTROL,
|
||||
STATIC_FILE_CACHE_CONTROL,
|
||||
register_first_available_frontend_static_routes,
|
||||
register_frontend_missing_fallback,
|
||||
register_frontend_static_routes,
|
||||
)
|
||||
@@ -28,8 +30,7 @@ def test_missing_dist_logs_error_and_keeps_app_running(tmp_path, caplog):
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 404
|
||||
assert "npm install" in resp.json()["detail"]
|
||||
assert "npm run build" in resp.json()["detail"]
|
||||
assert FRONTEND_BUILD_INSTRUCTIONS in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_missing_index_logs_error_and_skips_frontend_routes(tmp_path, caplog):
|
||||
@@ -120,3 +121,41 @@ def test_webmanifest_uses_forwarded_origin_headers(tmp_path):
|
||||
assert data["start_url"] == "https://mesh.example.com:8443/"
|
||||
assert data["scope"] == "https://mesh.example.com:8443/"
|
||||
assert data["id"] == "https://mesh.example.com:8443/"
|
||||
|
||||
|
||||
def test_first_available_prefers_dist_over_prebuilt(tmp_path):
|
||||
app = FastAPI()
|
||||
frontend_dir = tmp_path / "frontend"
|
||||
dist_dir = frontend_dir / "dist"
|
||||
prebuilt_dir = frontend_dir / "prebuilt"
|
||||
dist_dir.mkdir(parents=True)
|
||||
prebuilt_dir.mkdir(parents=True)
|
||||
(dist_dir / "index.html").write_text("<html><body>dist</body></html>")
|
||||
(prebuilt_dir / "index.html").write_text("<html><body>prebuilt</body></html>")
|
||||
|
||||
selected = register_first_available_frontend_static_routes(app, [dist_dir, prebuilt_dir])
|
||||
|
||||
assert selected == dist_dir.resolve()
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "dist" in response.text
|
||||
|
||||
|
||||
def test_first_available_uses_prebuilt_when_dist_missing(tmp_path):
|
||||
app = FastAPI()
|
||||
frontend_dir = tmp_path / "frontend"
|
||||
dist_dir = frontend_dir / "dist"
|
||||
prebuilt_dir = frontend_dir / "prebuilt"
|
||||
prebuilt_dir.mkdir(parents=True)
|
||||
(prebuilt_dir / "index.html").write_text("<html><body>prebuilt</body></html>")
|
||||
|
||||
selected = register_first_available_frontend_static_routes(app, [dist_dir, prebuilt_dir])
|
||||
|
||||
assert selected == prebuilt_dir.resolve()
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "prebuilt" in response.text
|
||||
|
||||