From 431d4d419a552c0122de18fee12be215f0c037c7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rkan?= <93771679+Bjorkan@users.noreply.github.com>
Date: Fri, 1 May 2026 08:15:50 +0200
Subject: [PATCH 1/3] Add Swagger UI api page on Automation page
---
app/api_docs.py | 515 ++++++++++++++++++
app/main.py | 6 +-
app/tcp_proxy/__init__.py | 0
.../settings/SettingsFanoutSection.tsx | 16 +-
frontend/src/test/fanoutSection.test.tsx | 12 +
frontend/tsconfig.json | 1 -
frontend/vite.config.ts | 8 +
tests/test_http_quality.py | 38 ++
8 files changed, 590 insertions(+), 6 deletions(-)
create mode 100644 app/api_docs.py
create mode 100644 app/tcp_proxy/__init__.py
diff --git a/app/api_docs.py b/app/api_docs.py
new file mode 100644
index 00000000..c3e97d8a
--- /dev/null
+++ b/app/api_docs.py
@@ -0,0 +1,515 @@
+"""Custom OpenAPI documentation page for the RemoteTerm API."""
+
+from __future__ import annotations
+
+import json
+from html import escape
+from typing import Any
+
+from fastapi import FastAPI
+from fastapi.openapi.utils import get_openapi
+from fastapi.responses import HTMLResponse
+
+API_DESCRIPTION = (
+ "RemoteTerm exposes the MeshCore companion radio as a local REST and WebSocket API.\n\n"
+ "REST endpoints are mounted below `/api`. The live WebSocket stream is available at "
+ "`/api/ws` for health, message, raw-packet, contact, and telemetry events.\n\n"
+ "**Trusted network note:** RemoteTerm is designed for trusted local networks. Optional "
+ "HTTP Basic auth can be enabled for the whole app, but operators should pair it with "
+ "HTTPS when credentials cross the network."
+)
+
+API_TAGS_METADATA: list[dict[str, Any]] = [
+ {
+ "name": "health",
+ "description": "Connection state, build info, database size, radio stats, and fanout health.",
+ },
+ {
+ "name": "debug",
+ "description": "Support snapshots for logs, live radio probes, drift audits, and version data.",
+ },
+ {
+ "name": "radio",
+ "description": "Radio configuration, connection lifecycle, discovery, trace, and advert commands.",
+ },
+ {
+ "name": "contacts",
+ "description": "Mesh contacts, analytics, read state, route overrides, and path discovery.",
+ },
+ {
+ "name": "repeaters",
+ "description": "Repeater login, telemetry, ACL, owner info, radio settings, and CLI commands.",
+ },
+ {
+ "name": "rooms",
+ "description": "Room-server login, status, telemetry, and ACL operations.",
+ },
+ {
+ "name": "channels",
+ "description": "Channel creation, metadata, read state, flood scope, and path-hash overrides.",
+ },
+ {
+ "name": "messages",
+ "description": "Message history, direct sends, channel sends, and channel resend workflows.",
+ },
+ {
+ "name": "packets",
+ "description": "Raw packet inspection, historical decryption, undecrypted counts, and maintenance.",
+ },
+ {
+ "name": "read-state",
+ "description": "Server-side unread counters, mention flags, and mark-all-read operations.",
+ },
+ {
+ "name": "settings",
+ "description": "App settings, favorites, muted channels, block lists, and telemetry tracking.",
+ },
+ {
+ "name": "push",
+ "description": "Browser Web Push subscriptions, per-device preferences, tests, and conversations.",
+ },
+ {
+ "name": "fanout",
+ "description": "MQTT, bots, webhooks, Apprise, SQS, Home Assistant, and map upload integrations.",
+ },
+ {
+ "name": "statistics",
+ "description": "Aggregated mesh, message, packet, channel, and contact statistics.",
+ },
+]
+
+SWAGGER_UI_CSS_URL = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css"
+SWAGGER_UI_BUNDLE_URL = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"
+SWAGGER_UI_PRESET_URL = (
+ "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-standalone-preset.js"
+)
+
+COMMON_ERROR_RESPONSES: dict[str, str] = {
+ "400": "Bad request",
+ "401": "Authentication required",
+ "403": "Forbidden",
+ "404": "Not found",
+ "408": "Request timed out",
+ "409": "Conflict",
+ "422": "Validation or command error",
+ "423": "Radio unavailable or locked",
+ "500": "Server error",
+}
+
+ERROR_RESPONSE_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "detail": {
+ "description": "Human-readable error detail or structured validation detail.",
+ "oneOf": [
+ {"type": "string"},
+ {"type": "array", "items": {}},
+ {"type": "object", "additionalProperties": True},
+ ],
+ }
+ },
+}
+
+
+def _relative_openapi_url(app: FastAPI) -> str:
+ """Keep docs usable behind reverse-proxy path prefixes."""
+ openapi_url = app.openapi_url or "/openapi.json"
+ return openapi_url.lstrip("/")
+
+
+def _error_response(description: str) -> dict[str, Any]:
+ return {
+ "description": description,
+ "content": {
+ "application/json": {
+ "schema": ERROR_RESPONSE_SCHEMA,
+ }
+ },
+ }
+
+
+def _add_common_error_responses(openapi_schema: dict[str, Any]) -> None:
+ paths = openapi_schema.get("paths")
+ if not isinstance(paths, dict):
+ return
+
+ for path_item in paths.values():
+ if not isinstance(path_item, dict):
+ continue
+ for operation in path_item.values():
+ if not isinstance(operation, dict):
+ continue
+ responses = operation.setdefault("responses", {})
+ if not isinstance(responses, dict):
+ continue
+ for status_code, description in COMMON_ERROR_RESPONSES.items():
+ responses.setdefault(status_code, _error_response(description))
+
+
+def install_custom_openapi(app: FastAPI) -> None:
+ """Install OpenAPI metadata polishing used by the custom docs page."""
+ original_openapi = app.openapi
+
+ def custom_openapi() -> dict[str, Any]:
+ if app.openapi_schema:
+ return app.openapi_schema
+
+ openapi_schema = get_openapi(
+ title=app.title,
+ version=app.version,
+ openapi_version=app.openapi_version,
+ description=app.description,
+ routes=app.routes,
+ tags=app.openapi_tags,
+ servers=app.servers,
+ )
+ _add_common_error_responses(openapi_schema)
+ app.openapi_schema = openapi_schema
+ return app.openapi_schema
+
+ # Keep a reference for debuggability and for tests that may introspect it.
+ app.state.default_openapi = original_openapi
+ app.openapi = custom_openapi # type: ignore[method-assign]
+
+
+def _build_swagger_docs_html(app: FastAPI) -> str:
+ title = escape(app.title)
+ version = escape(app.version)
+ openapi_url = json.dumps(_relative_openapi_url(app))
+
+ return f"""
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
RemoteTerm API
+
{title}
+
+ Explore radio control, messaging, packet inspection, push notifications,
+ and fanout integration endpoints from one interactive console.
+
+
+
+ Version {version}
+ REST base /api
+ Auth optional Basic
+
+
+
+
+
+
+
+
+
+"""
+
+
+def register_api_docs_routes(app: FastAPI) -> None:
+ """Register the custom Swagger UI route."""
+ install_custom_openapi(app)
+
+ @app.get("/docs", include_in_schema=False)
+ async def swagger_ui_html() -> HTMLResponse:
+ return HTMLResponse(_build_swagger_docs_html(app))
diff --git a/app/main.py b/app/main.py
index 3083b2c8..8cf66b37 100644
--- a/app/main.py
+++ b/app/main.py
@@ -44,6 +44,7 @@
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
+from app.api_docs import API_DESCRIPTION, API_TAGS_METADATA, register_api_docs_routes
from app.config import settings as server_settings
from app.config import setup_logging
from app.database import db
@@ -158,11 +159,14 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="RemoteTerm for MeshCore API",
- description="API for interacting with MeshCore mesh radio networks",
+ description=API_DESCRIPTION,
version=get_app_build_info().version,
+ openapi_tags=API_TAGS_METADATA,
+ docs_url=None,
lifespan=lifespan,
)
+register_api_docs_routes(app)
add_optional_basic_auth_middleware(app, server_settings)
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(
diff --git a/app/tcp_proxy/__init__.py b/app/tcp_proxy/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index 3efe9c15..adc4a056 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -8,7 +8,7 @@ import {
Suspense,
type ReactNode,
} from 'react';
-import { ChevronDown, Info } from 'lucide-react';
+import { BookOpen, ChevronDown, Info } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
@@ -3312,9 +3312,17 @@ export function SettingsFanoutSection({
)}
-
+
+
+
+
{
});
describe('SettingsFanoutSection', () => {
+ it('shows an API docs link beside the add integration button', async () => {
+ renderSection();
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
+ });
+ const apiDocsLink = screen.getByRole('link', { name: 'API Docs' });
+
+ expect(apiDocsLink).toHaveAttribute('href', './docs');
+ expect(apiDocsLink).toHaveAttribute('target', '_blank');
+ });
+
it('shows add integration dialog with all integration types', async () => {
renderSection();
const dialog = await openCreateIntegrationDialog();
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 5e1feb43..d5419222 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -15,7 +15,6 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
- "baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index f99c25ed..a11177e4 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -22,6 +22,14 @@ export default defineConfig({
changeOrigin: true,
ws: true,
},
+ '/docs': {
+ target: 'http://localhost:8000',
+ changeOrigin: true,
+ },
+ '/openapi.json': {
+ target: 'http://localhost:8000',
+ changeOrigin: true,
+ },
},
watch: {
usePolling: true,
diff --git a/tests/test_http_quality.py b/tests/test_http_quality.py
index 3efb0b06..5209cb25 100644
--- a/tests/test_http_quality.py
+++ b/tests/test_http_quality.py
@@ -11,3 +11,41 @@ def test_openapi_json_is_gzipped_when_client_accepts_gzip():
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
+
+
+def test_custom_swagger_docs_page_is_served():
+ with TestClient(app) as client:
+ response = client.get("/docs")
+
+ assert response.status_code == 200
+ assert "RemoteTerm API" in response.text
+ assert "SwaggerUIBundle" in response.text
+ assert 'url: "openapi.json"' in response.text
+ assert 'href="api/health"' in response.text
+ assert "repeating-linear-gradient" not in response.text
+ assert "background-size: 32px 32px" not in response.text
+
+
+def test_openapi_includes_docs_metadata():
+ with TestClient(app) as client:
+ response = client.get("/openapi.json")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["info"]["description"].startswith("RemoteTerm exposes")
+ assert "/docs" not in data["paths"]
+ tags = {tag["name"]: tag["description"] for tag in data["tags"]}
+ assert tags["messages"].startswith("Message history")
+ assert tags["radio"].startswith("Radio configuration")
+
+
+def test_openapi_documents_common_error_responses():
+ with TestClient(app) as client:
+ response = client.get("/openapi.json")
+
+ assert response.status_code == 200
+ data = response.json()
+ responses = data["paths"]["/api/messages/channel"]["post"]["responses"]
+ assert responses["400"]["description"] == "Bad request"
+ assert responses["423"]["description"] == "Radio unavailable or locked"
+ assert responses["500"]["description"] == "Server error"
From 32de22865e5246c1ee0e711294af2db0f5cd6a28 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rkan?= <93771679+Bjorkan@users.noreply.github.com>
Date: Fri, 1 May 2026 08:19:17 +0200
Subject: [PATCH 2/3] Removed empty app/tcp_proxy/__init__.py
---
app/tcp_proxy/__init__.py | 0
1 file changed, 0 insertions(+), 0 deletions(-)
delete mode 100644 app/tcp_proxy/__init__.py
diff --git a/app/tcp_proxy/__init__.py b/app/tcp_proxy/__init__.py
deleted file mode 100644
index e69de29b..00000000
From 40591610ea7a774f0f61194ff6b49a3c74f90f8d Mon Sep 17 00:00:00 2001
From: Bjorkan
Date: Thu, 14 May 2026 10:28:02 +0200
Subject: [PATCH 3/3] Removed button from Integration page
---
README.md | 2 +-
README_ADVANCED.md | 2 ++
.../components/settings/SettingsFanoutSection.tsx | 8 +-------
frontend/src/test/fanoutSection.test.tsx | 12 ------------
4 files changed, 4 insertions(+), 20 deletions(-)
diff --git a/README.md b/README.md
index b984f5d4..c4e84912 100644
--- a/README.md
+++ b/README.md
@@ -80,7 +80,7 @@ cd frontend && npm install && npm run build && cd ..
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
-Access the app at http://localhost:8000.
+Access the app at http://localhost:8000. Once the backend is running, the interactive API docs are available at http://localhost:8000/docs.
Source checkouts expect a normal frontend build in `frontend/dist`.
diff --git a/README_ADVANCED.md b/README_ADVANCED.md
index c18a9133..0755b2ed 100644
--- a/README_ADVANCED.md
+++ b/README_ADVANCED.md
@@ -1,5 +1,7 @@
# Advanced Setup And Troubleshooting
+Once the backend is running, FastAPI serves interactive API docs at `/docs` on the same host and port as the web UI. For a default local launch, that is http://localhost:8000/docs.
+
## Remediation & Advanced Environment Variables
These are intended for diagnosing or working around radios that behave oddly, or enabling advanced functionality.
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index adc4a056..02514642 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -8,7 +8,7 @@ import {
Suspense,
type ReactNode,
} from 'react';
-import { BookOpen, ChevronDown, Info } from 'lucide-react';
+import { ChevronDown, Info } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
@@ -3316,12 +3316,6 @@ export function SettingsFanoutSection({
-
{
});
describe('SettingsFanoutSection', () => {
- it('shows an API docs link beside the add integration button', async () => {
- renderSection();
-
- await waitFor(() => {
- expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
- });
- const apiDocsLink = screen.getByRole('link', { name: 'API Docs' });
-
- expect(apiDocsLink).toHaveAttribute('href', './docs');
- expect(apiDocsLink).toHaveAttribute('target', '_blank');
- });
-
it('shows add integration dialog with all integration types', async () => {
renderSection();
const dialog = await openCreateIntegrationDialog();