2ba20c7809
- 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>
131 lines
4.3 KiB
Python
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)}"
|
|
)
|