From 2ba20c7809420928201369b33f7cebd097f5a94c Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Mon, 13 Apr 2026 17:14:36 -0400 Subject: [PATCH] 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 --- backend/main.py | 9 +- backend/routers/sfm.py | 130 +++++ templates/base.html | 6 + templates/sfm.html | 1117 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1261 insertions(+), 1 deletion(-) create mode 100644 backend/routers/sfm.py create mode 100644 templates/sfm.html diff --git a/backend/main.py b/backend/main.py index 89cca81..81dfac9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,7 +18,7 @@ logging.basicConfig( logger = logging.getLogger(__name__) 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.models import IgnoredUnit 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_dashboard.router) app.include_router(seismo_dashboard.router) +app.include_router(sfm.router) app.include_router(modem_dashboard.router) from backend.routers import settings @@ -233,6 +234,12 @@ async def seismographs_page(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) async def modems_page(request: Request): """Field modems management dashboard""" diff --git a/backend/routers/sfm.py b/backend/routers/sfm.py new file mode 100644 index 0000000..5126284 --- /dev/null +++ b/backend/routers/sfm.py @@ -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)}" + ) diff --git a/templates/base.html b/templates/base.html index 36bd6c0..5cf5e81 100644 --- a/templates/base.html +++ b/templates/base.html @@ -122,6 +122,12 @@ Seismographs + + + + + SFM Live Data + diff --git a/templates/sfm.html b/templates/sfm.html new file mode 100644 index 0000000..e55e0c3 --- /dev/null +++ b/templates/sfm.html @@ -0,0 +1,1117 @@ +{% extends "base.html" %} + +{% block title %}SFM Event Data - Seismo Fleet Manager{% endblock %} + +{% block content %} +
+
+

SFM Event Data

+

Live device control and ACH event database

+
+
+ + Checking SFM… + + +
+
+ + +
+
+ Known Units + +
+
+ Total Events + +
+
+ Monitor Intervals + +
+
+ ACH Sessions + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+
+ Loading events… +
+
+
+ + + + + + + + + + + + + +
+ + + + + + + +{% endblock %}