Files
terra-view/backend/routers/sfm.py
claude 2ba20c7809 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>
2026-04-13 17:14:36 -04:00

131 lines
4.3 KiB
Python

"""
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)}"
)