feat(sfm): add SFM proxy router and event data page

- backend/routers/sfm.py: HTTP proxy to SFM backend (localhost:8200),
  mirrors the SLMM proxy pattern. SFM_BASE_URL env var for docker-compose.
  Catch-all /{path} forwards to SFM root (no /api/ prefix). 60s timeout.

- templates/sfm.html: full SFM dashboard with 5 tabs:
  Events (DB listing, filters by serial/date/false-trigger, flag/unflag FT),
  Units (known serials + stats, filter events by unit),
  Monitor Log (continuous monitoring intervals),
  ACH Sessions (call-home history),
  Live Device (TCP connect, device info cards, start/stop monitoring,
  push project config, download events from device, operation log).

- backend/main.py: import sfm router, include router, add GET /sfm route
- templates/base.html: add SFM Live Data nav link under Seismographs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 17:14:36 -04:00
parent 0f582a8a17
commit 2ba20c7809
4 changed files with 1261 additions and 1 deletions
+8 -1
View File
@@ -18,7 +18,7 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from backend.database import engine, Base, get_db from backend.database import engine, Base, get_db
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, sfm, projects, project_locations, scheduler, modem_dashboard
from backend.services.snapshot import emit_status_snapshot from backend.services.snapshot import emit_status_snapshot
from backend.models import IgnoredUnit from backend.models import IgnoredUnit
from backend.utils.timezone import get_user_timezone from backend.utils.timezone import get_user_timezone
@@ -97,6 +97,7 @@ app.include_router(slmm.router)
app.include_router(slm_ui.router) app.include_router(slm_ui.router)
app.include_router(slm_dashboard.router) app.include_router(slm_dashboard.router)
app.include_router(seismo_dashboard.router) app.include_router(seismo_dashboard.router)
app.include_router(sfm.router)
app.include_router(modem_dashboard.router) app.include_router(modem_dashboard.router)
from backend.routers import settings from backend.routers import settings
@@ -233,6 +234,12 @@ async def seismographs_page(request: Request):
return templates.TemplateResponse("seismographs.html", {"request": request}) return templates.TemplateResponse("seismographs.html", {"request": request})
@app.get("/sfm", response_class=HTMLResponse)
async def sfm_page(request: Request):
"""SFM live event data and device control dashboard"""
return templates.TemplateResponse("sfm.html", {"request": request})
@app.get("/modems", response_class=HTMLResponse) @app.get("/modems", response_class=HTMLResponse)
async def modems_page(request: Request): async def modems_page(request: Request):
"""Field modems management dashboard""" """Field modems management dashboard"""
+130
View File
@@ -0,0 +1,130 @@
"""
SFM (Seismograph Field Module) Proxy Router
Proxies requests from terra-view to the standalone SFM backend service.
SFM runs on port 8200 and handles MiniMate Plus seismograph communication
and event database queries.
SFM endpoints are at root level (e.g. /db/units, /device/info) — no /api/ prefix.
"""
from fastapi import APIRouter, HTTPException, Request, Response
import httpx
import logging
import os
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/sfm", tags=["sfm"])
# SFM backend URL - configurable via environment variable
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
@router.get("/health")
async def check_sfm_health():
"""
Check if the SFM backend service is reachable and healthy.
"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{SFM_BASE_URL}/health")
if response.status_code == 200:
data = response.json()
return {
"status": "ok",
"sfm_status": "connected",
"sfm_url": SFM_BASE_URL,
"sfm_response": data
}
else:
return {
"status": "degraded",
"sfm_status": "error",
"sfm_url": SFM_BASE_URL,
"detail": f"SFM returned status {response.status_code}"
}
except httpx.ConnectError:
return {
"status": "error",
"sfm_status": "unreachable",
"sfm_url": SFM_BASE_URL,
"detail": "Cannot connect to SFM backend. Is it running?"
}
except Exception as e:
return {
"status": "error",
"sfm_status": "error",
"sfm_url": SFM_BASE_URL,
"detail": str(e)
}
# HTTP catch-all — proxies everything else to SFM backend
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_to_sfm(path: str, request: Request):
"""
Proxy all requests to the SFM backend service.
SFM endpoints have no /api/ prefix — target URL is {SFM_BASE_URL}/{path}.
Timeout is 60s to allow for live device round-trips (event downloads can
take 30-45s for a full event list).
"""
# Build target URL — SFM endpoints live at root, not /api/
target_url = f"{SFM_BASE_URL}/{path}"
# Forward query params
query_params = dict(request.query_params)
# Read body for mutation requests
body = None
if request.method in ["POST", "PUT", "PATCH"]:
try:
body = await request.body()
except Exception as e:
logger.error(f"Failed to read request body: {e}")
body = None
# Strip hop-by-hop headers
headers = dict(request.headers)
headers_to_exclude = ["host", "content-length", "transfer-encoding", "connection"]
proxy_headers = {k: v for k, v in headers.items() if k.lower() not in headers_to_exclude}
logger.info(f"Proxying {request.method} {path} → SFM: {target_url}")
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.request(
method=request.method,
url=target_url,
params=query_params,
headers=proxy_headers,
content=body
)
return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.headers.get("content-type")
)
except httpx.ConnectError:
logger.error(f"Failed to connect to SFM backend at {SFM_BASE_URL}")
raise HTTPException(
status_code=503,
detail=f"SFM backend service unavailable. Is SFM running on {SFM_BASE_URL}?"
)
except httpx.TimeoutException:
logger.error(f"Timeout connecting to SFM backend at {SFM_BASE_URL}")
raise HTTPException(
status_code=504,
detail="SFM backend timeout"
)
except Exception as e:
logger.error(f"Error proxying to SFM: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to proxy request to SFM: {str(e)}"
)
+6
View File
@@ -122,6 +122,12 @@
</svg> </svg>
Seismographs Seismographs
</a> </a>
<a href="/sfm" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sfm' %}bg-gray-100 dark:bg-gray-700{% endif %}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
SFM Live Data
</a>
<a href="/sound-level-meters" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sound-level-meters' %}bg-gray-100 dark:bg-gray-700{% endif %}"> <a href="/sound-level-meters" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sound-level-meters' %}bg-gray-100 dark:bg-gray-700{% endif %}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+1117
View File
File diff suppressed because it is too large Load Diff