architecture: remove redundant SFM service and simplify deployment

This commit is contained in:
serversdwn
2026-01-09 20:58:16 +00:00
parent 94354da611
commit 7715123053
6 changed files with 207 additions and 149 deletions

83
app/api/slmm_proxy.py Normal file
View File

@@ -0,0 +1,83 @@
"""
SLMM API Proxy
Forwards /api/slmm/* requests to the SLMM backend service
"""
import httpx
import logging
from fastapi import APIRouter, Request, Response, WebSocket
from fastapi.responses import StreamingResponse
from app.core.config import SLMM_API_URL
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/slmm", tags=["slmm-proxy"])
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_slmm_request(path: str, request: Request):
"""Proxy HTTP requests to SLMM backend"""
# Build target URL - rewrite /api/slmm/* to /api/nl43/*
target_url = f"{SLMM_API_URL}/api/nl43/{path}"
# Get query params
query_string = str(request.url.query)
if query_string:
target_url += f"?{query_string}"
logger.info(f"Proxying {request.method} {target_url}")
# Read request body
body = await request.body()
# Forward headers (exclude host)
headers = {
key: value
for key, value in request.headers.items()
if key.lower() not in ['host', 'content-length']
}
async with httpx.AsyncClient(timeout=30.0) as client:
try:
# Make proxied request
response = await client.request(
method=request.method,
url=target_url,
content=body,
headers=headers
)
# Return response
return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers)
)
except httpx.RequestError as e:
logger.error(f"Proxy request failed: {e}")
return Response(
content=f'{{"detail": "SLMM backend unavailable: {str(e)}"}}',
status_code=502,
media_type="application/json"
)
@router.websocket("/{unit_id}/live")
async def proxy_slmm_websocket(websocket: WebSocket, unit_id: str):
"""Proxy WebSocket connections to SLMM backend for live data streaming"""
await websocket.accept()
# Build WebSocket URL
ws_protocol = "ws" if "localhost" in SLMM_API_URL or "127.0.0.1" in SLMM_API_URL else "wss"
ws_url = SLMM_API_URL.replace("http://", f"{ws_protocol}://").replace("https://", f"{ws_protocol}://")
ws_target = f"{ws_url}/api/slmm/{unit_id}/live"
logger.info(f"Proxying WebSocket to {ws_target}")
async with httpx.AsyncClient() as client:
try:
async with client.stream("GET", ws_target) as response:
async for chunk in response.aiter_bytes():
await websocket.send_bytes(chunk)
except Exception as e:
logger.error(f"WebSocket proxy error: {e}")
await websocket.close(code=1011, reason=f"Backend error: {str(e)}")

View File

@@ -12,6 +12,8 @@ ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
PORT = int(os.getenv("PORT", 8001)) PORT = int(os.getenv("PORT", 8001))
# External Services # External Services
# Terra-View is a unified application with seismograph logic built-in
# The only external HTTP dependency is SLMM for NL-43 device communication
SLMM_API_URL = os.getenv("SLMM_API_URL", "http://localhost:8100") SLMM_API_URL = os.getenv("SLMM_API_URL", "http://localhost:8100")
# Database URLs (feature-specific) # Database URLs (feature-specific)

View File

@@ -112,6 +112,10 @@ app.include_router(seismo_legacy_routes.router)
app.include_router(slm_router) app.include_router(slm_router)
app.include_router(slm_dashboard_router) app.include_router(slm_dashboard_router)
# SLMM Backend Proxy (forward /api/slmm/* to SLMM service)
from app.api import slmm_proxy
app.include_router(slmm_proxy.router)
# API Aggregation Layer (future cross-feature endpoints) # API Aggregation Layer (future cross-feature endpoints)
# app.include_router(api_dashboard.router) # TODO: Implement aggregation # app.include_router(api_dashboard.router) # TODO: Implement aggregation
# app.include_router(api_roster.router) # TODO: Implement aggregation # app.include_router(api_roster.router) # TODO: Implement aggregation

View File

@@ -2,108 +2,145 @@
Dashboard API endpoints for SLM/NL43 devices. Dashboard API endpoints for SLM/NL43 devices.
This layer aggregates and transforms data from the device API for UI consumption. This layer aggregates and transforms data from the device API for UI consumption.
""" """
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
from typing import List, Dict, Any from typing import List, Dict, Any
import logging import logging
from app.slm.database import get_db from app.slm.database import get_db as get_slm_db
from app.slm.models import NL43Config, NL43Status from app.slm.models import NL43Config, NL43Status
from app.slm.services import NL43Client from app.slm.services import NL43Client
# Import seismo database for roster data
from app.seismo.database import get_db as get_seismo_db
from app.seismo.models import RosterUnit
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"]) router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
templates = Jinja2Templates(directory="app/ui/templates")
@router.get("/stats") @router.get("/stats", response_class=HTMLResponse)
async def get_dashboard_stats(db: Session = Depends(get_db)): async def get_dashboard_stats(request: Request, db: Session = Depends(get_seismo_db)):
"""Get aggregate statistics for the SLM dashboard.""" """Get aggregate statistics for the SLM dashboard from roster (returns HTML)."""
total_units = db.query(func.count(NL43Config.unit_id)).scalar() or 0 # Query SLMs from the roster
slms = db.query(RosterUnit).filter_by(
device_type="sound_level_meter",
retired=False
).all()
# Count units with recent status updates (within last 5 minutes) total_units = len(slms)
from datetime import datetime, timedelta deployed = sum(1 for s in slms if s.deployed)
five_min_ago = datetime.utcnow() - timedelta(minutes=5) benched = sum(1 for s in slms if not s.deployed)
online_units = db.query(func.count(NL43Status.unit_id)).filter(
NL43Status.last_seen >= five_min_ago
).scalar() or 0
# Count units currently measuring # For "active", count SLMs with recent check-ins (within last hour)
measuring_units = db.query(func.count(NL43Status.unit_id)).filter( from datetime import datetime, timedelta, timezone
NL43Status.measurement_state == "Measure" one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
).scalar() or 0 active = sum(1 for s in slms if s.slm_last_check and s.slm_last_check >= one_hour_ago)
return { # Map to template variable names
"total_units": total_units, # total_count, deployed_count, active_count, benched_count
"online_units": online_units, return templates.TemplateResponse(
"offline_units": total_units - online_units, "partials/slm_stats.html",
"measuring_units": measuring_units, {
"idle_units": online_units - measuring_units "request": request,
"total_count": total_units,
"deployed_count": deployed,
"active_count": active,
"benched_count": benched
} }
)
@router.get("/units") @router.get("/units", response_class=HTMLResponse)
async def get_units_list(db: Session = Depends(get_db)): async def get_units_list(request: Request, db: Session = Depends(get_seismo_db)):
"""Get list of all NL43 units with their latest status.""" """Get list of all SLM units from roster (returns HTML)."""
configs = db.query(NL43Config).all() # Query SLMs from the roster (not retired)
slms = db.query(RosterUnit).filter_by(
device_type="sound_level_meter",
retired=False
).order_by(RosterUnit.id).all()
units = [] units = []
for slm in slms:
for config in configs: # Map to template field names
status = db.query(NL43Status).filter_by(unit_id=config.unit_id).first()
# Determine if unit is online (status updated within last 5 minutes)
from datetime import datetime, timedelta
is_online = False
if status and status.last_seen:
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
is_online = status.last_seen >= five_min_ago
unit_data = { unit_data = {
"unit_id": config.unit_id, "id": slm.id,
"host": config.host, "slm_host": slm.slm_host,
"tcp_port": config.tcp_port, "slm_tcp_port": slm.slm_tcp_port,
"tcp_enabled": config.tcp_enabled, "slm_last_check": slm.slm_last_check,
"is_online": is_online, "slm_model": slm.slm_model or "NL-43",
"measurement_state": status.measurement_state if status else "unknown", "address": slm.address,
"last_seen": status.last_seen.isoformat() if status and status.last_seen else None, "deployed_with_modem_id": slm.deployed_with_modem_id,
"lp": status.lp if status else None,
"leq": status.leq if status else None,
"lmax": status.lmax if status else None,
"battery_level": status.battery_level if status else None,
} }
units.append(unit_data) units.append(unit_data)
return {"units": units} return templates.TemplateResponse(
"partials/slm_unit_list.html",
{
@router.get("/live-view/{unit_id}") "request": request,
async def get_live_view(unit_id: str, db: Session = Depends(get_db)): "units": units
"""Get live measurement data for a specific unit."""
status = db.query(NL43Status).filter_by(unit_id=unit_id).first()
if not status:
raise HTTPException(status_code=404, detail="Unit not found")
return {
"unit_id": unit_id,
"last_seen": status.last_seen.isoformat() if status.last_seen else None,
"measurement_state": status.measurement_state,
"measurement_start_time": status.measurement_start_time.isoformat() if status.measurement_start_time else None,
"counter": status.counter,
"lp": status.lp,
"leq": status.leq,
"lmax": status.lmax,
"lmin": status.lmin,
"lpeak": status.lpeak,
"battery_level": status.battery_level,
"power_source": status.power_source,
"sd_remaining_mb": status.sd_remaining_mb,
"sd_free_ratio": status.sd_free_ratio,
} }
)
@router.get("/live-view/{unit_id}", response_class=HTMLResponse)
async def get_live_view(unit_id: str, request: Request, slm_db: Session = Depends(get_slm_db), roster_db: Session = Depends(get_seismo_db)):
"""Get live measurement data for a specific unit (returns HTML)."""
# Get unit from roster
unit = roster_db.query(RosterUnit).filter_by(
id=unit_id,
device_type="sound_level_meter"
).first()
if not unit:
return templates.TemplateResponse(
"partials/slm_live_view_error.html",
{
"request": request,
"error": f"Unit {unit_id} not found in roster"
}
)
# Get status from monitoring database (may not exist yet)
status = slm_db.query(NL43Status).filter_by(unit_id=unit_id).first()
# Get modem info if available
modem = None
modem_ip = None
if unit.deployed_with_modem_id:
modem = roster_db.query(RosterUnit).filter_by(
id=unit.deployed_with_modem_id,
device_type="modem"
).first()
if modem:
modem_ip = modem.ip_address
elif unit.slm_host:
modem_ip = unit.slm_host
# Determine if measuring
is_measuring = False
if status and status.measurement_state:
is_measuring = status.measurement_state.lower() == 'start'
return templates.TemplateResponse(
"partials/slm_live_view.html",
{
"request": request,
"unit": unit,
"modem": modem,
"modem_ip": modem_ip,
"current_status": status,
"is_measuring": is_measuring
}
)
@router.get("/config/{unit_id}") @router.get("/config/{unit_id}")
async def get_unit_config(unit_id: str, db: Session = Depends(get_db)): async def get_unit_config(unit_id: str, db: Session = Depends(get_slm_db)):
"""Get configuration for a specific unit.""" """Get configuration for a specific unit."""
config = db.query(NL43Config).filter_by(unit_id=unit_id).first() config = db.query(NL43Config).filter_by(unit_id=unit_id).first()
if not config: if not config:
@@ -122,7 +159,7 @@ async def get_unit_config(unit_id: str, db: Session = Depends(get_db)):
@router.post("/config/{unit_id}") @router.post("/config/{unit_id}")
async def update_unit_config(unit_id: str, config_data: Dict[str, Any], db: Session = Depends(get_db)): async def update_unit_config(unit_id: str, config_data: Dict[str, Any], db: Session = Depends(get_slm_db)):
"""Update configuration for a specific unit.""" """Update configuration for a specific unit."""
config = db.query(NL43Config).filter_by(unit_id=unit_id).first() config = db.query(NL43Config).filter_by(unit_id=unit_id).first()
@@ -154,7 +191,7 @@ async def update_unit_config(unit_id: str, config_data: Dict[str, Any], db: Sess
@router.post("/control/{unit_id}/{action}") @router.post("/control/{unit_id}/{action}")
async def control_unit(unit_id: str, action: str, db: Session = Depends(get_db)): async def control_unit(unit_id: str, action: str, db: Session = Depends(get_slm_db)):
"""Send control command to a unit (start, stop, pause, resume, etc.).""" """Send control command to a unit (start, stop, pause, resume, etc.)."""
config = db.query(NL43Config).filter_by(unit_id=unit_id).first() config = db.query(NL43Config).filter_by(unit_id=unit_id).first()
if not config: if not config:
@@ -201,7 +238,7 @@ async def control_unit(unit_id: str, action: str, db: Session = Depends(get_db))
@router.get("/test-modem/{unit_id}") @router.get("/test-modem/{unit_id}")
async def test_modem(unit_id: str, db: Session = Depends(get_db)): async def test_modem(unit_id: str, db: Session = Depends(get_slm_db)):
"""Test connectivity to a unit's modem/device.""" """Test connectivity to a unit's modem/device."""
config = db.query(NL43Config).filter_by(unit_id=unit_id).first() config = db.query(NL43Config).filter_by(unit_id=unit_id).first()
if not config: if not config:

41
dfjkl
View File

@@ -1,41 +0,0 @@
16eb9eb (HEAD -> dev) migration Part 1.
991aaca chore: modular monolith folder split (no behavior change)
893cb96 fixed syntax error, unexpected token
c30d7fa (origin/dev) SLM config now sync to SLMM, SLMM caches configs for speed
6d34e54 Update Terra-view SLM live view to use correct DRD field names
4d74eda 0.4.2 - Early implementation of SLMs. WIP.
96cb27e v0.4.1
85b211e bugfix: unit status updating based on last heard, not just using cached status
e16f61a slm integration added
dba4ad1 refactor: clean up whitespace and improve formatting in emit_status_snapshot function
e78d252 (origin/wip/nl43-scaffold-backup, wip/nl43-scaffold-backup) .dockerignore improve for deployment
ab9c650 .dockerignore improve for deployment
2d22d0d docs updated to v0.4.0
7d17d35 settings oragnized, DB management system fixes
7c89d20 renamed datamanagement to roster management
27f8719 db management system added
d97999e unit history added
191dcef Photo mode feature added.
6db958f map overlap bug fixed
3a41b81 docs updated for 0.3.2
3aff0cb fixed mobile modal view status
7cadd97 Bump version to v0.3.2
274e390 Full PWA mobile version added, bug fixes on deployment status, navigation links added
195df96 0.3.0 update-docs updated
6fc8721 settings overhaul, many QOL improvements
690669c v0.2.2-series4 endpoint added, dev branch set up at :1001
83593f7 update docs
4cef580 v0.2.1. many features added and cleaned up.
dc85380 v0.2 fleet overhaul
802601a pre refactor
e46f668 added frontend unit addition/editing
90ecada v0.1.1 update
938e950 Merge pull request #3 from serversdwn/main
a6ad9fd Merge pull request #2 from serversdwn/claude/seismo-frontend-scaffold-015sto5mf2MpPCE57TbNKtaF
02a99ea (origin/claude/seismo-frontend-scaffold-015sto5mf2MpPCE57TbNKtaF, claude/seismo-frontend-scaffold-015sto5mf2MpPCE57TbNKtaF) Fix Docker configuration for new backend structure
247405c Add MVP frontend scaffold with FastAPI + HTMX + TailwindCSS
e7e660a Merge pull request #1 from serversdwn/claude/seismo-backend-server-01FsCdpT2WT4B342V3KtWx38
36ce63f (origin/claude/seismo-backend-server-01FsCdpT2WT4B342V3KtWx38, claude/seismo-backend-server-01FsCdpT2WT4B342V3KtWx38) Change exposed port from 8000 to 8001 to avoid port conflict
05c6336 Containerize backend with Docker Compose
f976e4e Add Seismo Fleet Manager backend v0.1
c1bdf17 (origin/main, origin/HEAD, main) Initial commit

View File

@@ -1,7 +1,8 @@
services: services:
# --- TERRA-VIEW UI/ORCHESTRATOR (PRODUCTION) --- # --- TERRA-VIEW (PRODUCTION) ---
# Serves HTML, proxies to SFM and SLMM backends # Unified application: UI + Seismograph logic + SLM dashboard/proxy
# Only external HTTP dependency: SLMM backend for NL-43 device communication
terra-view-prod: terra-view-prod:
build: build:
context: . context: .
@@ -14,13 +15,11 @@ services:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- ENVIRONMENT=production - ENVIRONMENT=production
- PORT=8001 - PORT=8001
- SFM_API_URL=http://localhost:8002 # New: Points to SFM container
- SLMM_API_URL=http://localhost:8100 # Points to SLMM container - SLMM_API_URL=http://localhost:8100 # Points to SLMM container
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- sfm
- slmm - slmm
command: python3 -m app.main # Runs full Terra-View (UI + orchestration) command: python3 -m app.main # Runs full Terra-View (UI + seismo + SLM)
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"] test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s interval: 30s
@@ -28,7 +27,7 @@ services:
retries: 3 retries: 3
start_period: 40s start_period: 40s
# --- TERRA-VIEW UI/ORCHESTRATOR (DEVELOPMENT) --- # --- TERRA-VIEW (DEVELOPMENT) ---
terra-view-dev: terra-view-dev:
build: build:
context: . context: .
@@ -41,11 +40,9 @@ services:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- ENVIRONMENT=development - ENVIRONMENT=development
- PORT=1001 - PORT=1001
- SFM_API_URL=http://localhost:8002
- SLMM_API_URL=http://localhost:8100 - SLMM_API_URL=http://localhost:8100
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- sfm
- slmm - slmm
profiles: profiles:
- dev - dev
@@ -57,30 +54,6 @@ services:
retries: 3 retries: 3
start_period: 40s start_period: 40s
# --- SFM - SEISMOGRAPH FLEET MODULE (BACKEND API) ---
# Eventually will be API-only, but for now runs same code as Terra-View
sfm:
build:
context: .
dockerfile: Dockerfile.sfm
container_name: sfm
network_mode: host
volumes:
- ./data:/app/data
environment:
- PYTHONUNBUFFERED=1
- ENVIRONMENT=production
- PORT=8002 # Different port from Terra-View
- MODULE_MODE=sfm # Future: Tells app to run SFM-only mode
restart: unless-stopped
command: python3 -m app.main # For now: Same entry point, different port
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# --- SLMM (Sound Level Meter Manager) --- # --- SLMM (Sound Level Meter Manager) ---
slmm: slmm:
build: build: