architecture: remove redundant SFM service and simplify deployment
This commit is contained in:
83
app/api/slmm_proxy.py
Normal file
83
app/api/slmm_proxy.py
Normal 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)}")
|
||||
@@ -12,6 +12,8 @@ ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
PORT = int(os.getenv("PORT", 8001))
|
||||
|
||||
# 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")
|
||||
|
||||
# Database URLs (feature-specific)
|
||||
|
||||
@@ -112,6 +112,10 @@ app.include_router(seismo_legacy_routes.router)
|
||||
app.include_router(slm_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)
|
||||
# app.include_router(api_dashboard.router) # TODO: Implement aggregation
|
||||
# app.include_router(api_roster.router) # TODO: Implement aggregation
|
||||
|
||||
@@ -2,108 +2,145 @@
|
||||
Dashboard API endpoints for SLM/NL43 devices.
|
||||
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 import func
|
||||
from typing import List, Dict, Any
|
||||
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.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__)
|
||||
|
||||
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
|
||||
templates = Jinja2Templates(directory="app/ui/templates")
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_dashboard_stats(db: Session = Depends(get_db)):
|
||||
"""Get aggregate statistics for the SLM dashboard."""
|
||||
total_units = db.query(func.count(NL43Config.unit_id)).scalar() or 0
|
||||
@router.get("/stats", response_class=HTMLResponse)
|
||||
async def get_dashboard_stats(request: Request, db: Session = Depends(get_seismo_db)):
|
||||
"""Get aggregate statistics for the SLM dashboard from roster (returns HTML)."""
|
||||
# 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)
|
||||
from datetime import datetime, timedelta
|
||||
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
|
||||
online_units = db.query(func.count(NL43Status.unit_id)).filter(
|
||||
NL43Status.last_seen >= five_min_ago
|
||||
).scalar() or 0
|
||||
total_units = len(slms)
|
||||
deployed = sum(1 for s in slms if s.deployed)
|
||||
benched = sum(1 for s in slms if not s.deployed)
|
||||
|
||||
# Count units currently measuring
|
||||
measuring_units = db.query(func.count(NL43Status.unit_id)).filter(
|
||||
NL43Status.measurement_state == "Measure"
|
||||
).scalar() or 0
|
||||
# For "active", count SLMs with recent check-ins (within last hour)
|
||||
from datetime import datetime, timedelta, timezone
|
||||
one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
active = sum(1 for s in slms if s.slm_last_check and s.slm_last_check >= one_hour_ago)
|
||||
|
||||
return {
|
||||
"total_units": total_units,
|
||||
"online_units": online_units,
|
||||
"offline_units": total_units - online_units,
|
||||
"measuring_units": measuring_units,
|
||||
"idle_units": online_units - measuring_units
|
||||
}
|
||||
# Map to template variable names
|
||||
# total_count, deployed_count, active_count, benched_count
|
||||
return templates.TemplateResponse(
|
||||
"partials/slm_stats.html",
|
||||
{
|
||||
"request": request,
|
||||
"total_count": total_units,
|
||||
"deployed_count": deployed,
|
||||
"active_count": active,
|
||||
"benched_count": benched
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/units")
|
||||
async def get_units_list(db: Session = Depends(get_db)):
|
||||
"""Get list of all NL43 units with their latest status."""
|
||||
configs = db.query(NL43Config).all()
|
||||
@router.get("/units", response_class=HTMLResponse)
|
||||
async def get_units_list(request: Request, db: Session = Depends(get_seismo_db)):
|
||||
"""Get list of all SLM units from roster (returns HTML)."""
|
||||
# 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 = []
|
||||
|
||||
for config in configs:
|
||||
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
|
||||
|
||||
for slm in slms:
|
||||
# Map to template field names
|
||||
unit_data = {
|
||||
"unit_id": config.unit_id,
|
||||
"host": config.host,
|
||||
"tcp_port": config.tcp_port,
|
||||
"tcp_enabled": config.tcp_enabled,
|
||||
"is_online": is_online,
|
||||
"measurement_state": status.measurement_state if status else "unknown",
|
||||
"last_seen": status.last_seen.isoformat() if status and status.last_seen else None,
|
||||
"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,
|
||||
"id": slm.id,
|
||||
"slm_host": slm.slm_host,
|
||||
"slm_tcp_port": slm.slm_tcp_port,
|
||||
"slm_last_check": slm.slm_last_check,
|
||||
"slm_model": slm.slm_model or "NL-43",
|
||||
"address": slm.address,
|
||||
"deployed_with_modem_id": slm.deployed_with_modem_id,
|
||||
}
|
||||
units.append(unit_data)
|
||||
|
||||
return {"units": units}
|
||||
return templates.TemplateResponse(
|
||||
"partials/slm_unit_list.html",
|
||||
{
|
||||
"request": request,
|
||||
"units": units
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/live-view/{unit_id}")
|
||||
async def get_live_view(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""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")
|
||||
@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()
|
||||
|
||||
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,
|
||||
}
|
||||
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}")
|
||||
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."""
|
||||
config = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||
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}")
|
||||
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."""
|
||||
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}")
|
||||
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.)."""
|
||||
config = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||
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}")
|
||||
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."""
|
||||
config = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||
if not config:
|
||||
|
||||
41
dfjkl
41
dfjkl
@@ -1,41 +0,0 @@
|
||||
[33m16eb9eb[m[33m ([m[1;36mHEAD -> [m[1;32mdev[m[33m)[m migration Part 1.
|
||||
[33m991aaca[m chore: modular monolith folder split (no behavior change)
|
||||
[33m893cb96[m fixed syntax error, unexpected token
|
||||
[33mc30d7fa[m[33m ([m[1;31morigin/dev[m[33m)[m SLM config now sync to SLMM, SLMM caches configs for speed
|
||||
[33m6d34e54[m Update Terra-view SLM live view to use correct DRD field names
|
||||
[33m4d74eda[m 0.4.2 - Early implementation of SLMs. WIP.
|
||||
[33m96cb27e[m v0.4.1
|
||||
[33m85b211e[m bugfix: unit status updating based on last heard, not just using cached status
|
||||
[33me16f61a[m slm integration added
|
||||
[33mdba4ad1[m refactor: clean up whitespace and improve formatting in emit_status_snapshot function
|
||||
[33me78d252[m[33m ([m[1;31morigin/wip/nl43-scaffold-backup[m[33m, [m[1;32mwip/nl43-scaffold-backup[m[33m)[m .dockerignore improve for deployment
|
||||
[33mab9c650[m .dockerignore improve for deployment
|
||||
[33m2d22d0d[m docs updated to v0.4.0
|
||||
[33m7d17d35[m settings oragnized, DB management system fixes
|
||||
[33m7c89d20[m renamed datamanagement to roster management
|
||||
[33m27f8719[m db management system added
|
||||
[33md97999e[m unit history added
|
||||
[33m191dcef[m Photo mode feature added.
|
||||
[33m6db958f[m map overlap bug fixed
|
||||
[33m3a41b81[m docs updated for 0.3.2
|
||||
[33m3aff0cb[m fixed mobile modal view status
|
||||
[33m7cadd97[m Bump version to v0.3.2
|
||||
[33m274e390[m Full PWA mobile version added, bug fixes on deployment status, navigation links added
|
||||
[33m195df96[m 0.3.0 update-docs updated
|
||||
[33m6fc8721[m settings overhaul, many QOL improvements
|
||||
[33m690669c[m v0.2.2-series4 endpoint added, dev branch set up at :1001
|
||||
[33m83593f7[m update docs
|
||||
[33m4cef580[m v0.2.1. many features added and cleaned up.
|
||||
[33mdc85380[m v0.2 fleet overhaul
|
||||
[33m802601a[m pre refactor
|
||||
[33me46f668[m added frontend unit addition/editing
|
||||
[33m90ecada[m v0.1.1 update
|
||||
[33m938e950[m Merge pull request #3 from serversdwn/main
|
||||
[33ma6ad9fd[m Merge pull request #2 from serversdwn/claude/seismo-frontend-scaffold-015sto5mf2MpPCE57TbNKtaF
|
||||
[33m02a99ea[m[33m ([m[1;31morigin/claude/seismo-frontend-scaffold-015sto5mf2MpPCE57TbNKtaF[m[33m, [m[1;32mclaude/seismo-frontend-scaffold-015sto5mf2MpPCE57TbNKtaF[m[33m)[m Fix Docker configuration for new backend structure
|
||||
[33m247405c[m Add MVP frontend scaffold with FastAPI + HTMX + TailwindCSS
|
||||
[33me7e660a[m Merge pull request #1 from serversdwn/claude/seismo-backend-server-01FsCdpT2WT4B342V3KtWx38
|
||||
[33m36ce63f[m[33m ([m[1;31morigin/claude/seismo-backend-server-01FsCdpT2WT4B342V3KtWx38[m[33m, [m[1;32mclaude/seismo-backend-server-01FsCdpT2WT4B342V3KtWx38[m[33m)[m Change exposed port from 8000 to 8001 to avoid port conflict
|
||||
[33m05c6336[m Containerize backend with Docker Compose
|
||||
[33mf976e4e[m Add Seismo Fleet Manager backend v0.1
|
||||
[33mc1bdf17[m[33m ([m[1;31morigin/main[m[33m, [m[1;31morigin/HEAD[m[33m, [m[1;32mmain[m[33m)[m Initial commit
|
||||
@@ -1,7 +1,8 @@
|
||||
services:
|
||||
|
||||
# --- TERRA-VIEW UI/ORCHESTRATOR (PRODUCTION) ---
|
||||
# Serves HTML, proxies to SFM and SLMM backends
|
||||
# --- TERRA-VIEW (PRODUCTION) ---
|
||||
# Unified application: UI + Seismograph logic + SLM dashboard/proxy
|
||||
# Only external HTTP dependency: SLMM backend for NL-43 device communication
|
||||
terra-view-prod:
|
||||
build:
|
||||
context: .
|
||||
@@ -14,13 +15,11 @@ services:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- ENVIRONMENT=production
|
||||
- PORT=8001
|
||||
- SFM_API_URL=http://localhost:8002 # New: Points to SFM container
|
||||
- SLMM_API_URL=http://localhost:8100 # Points to SLMM container
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- sfm
|
||||
- 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:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||
interval: 30s
|
||||
@@ -28,7 +27,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# --- TERRA-VIEW UI/ORCHESTRATOR (DEVELOPMENT) ---
|
||||
# --- TERRA-VIEW (DEVELOPMENT) ---
|
||||
terra-view-dev:
|
||||
build:
|
||||
context: .
|
||||
@@ -41,11 +40,9 @@ services:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- ENVIRONMENT=development
|
||||
- PORT=1001
|
||||
- SFM_API_URL=http://localhost:8002
|
||||
- SLMM_API_URL=http://localhost:8100
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- sfm
|
||||
- slmm
|
||||
profiles:
|
||||
- dev
|
||||
@@ -57,30 +54,6 @@ services:
|
||||
retries: 3
|
||||
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:
|
||||
build:
|
||||
|
||||
Reference in New Issue
Block a user