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:
+8
-1
@@ -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"""
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
Reference in New Issue
Block a user