diff --git a/app/api/slmm_proxy.py b/app/api/slmm_proxy.py new file mode 100644 index 0000000..59a9819 --- /dev/null +++ b/app/api/slmm_proxy.py @@ -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)}") diff --git a/app/core/config.py b/app/core/config.py index 131ddd4..d1cbc5f 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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) diff --git a/app/main.py b/app/main.py index 5e10fc2..3990003 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/slm/dashboard.py b/app/slm/dashboard.py index 7230332..07bf1eb 100644 --- a/app/slm/dashboard.py +++ b/app/slm/dashboard.py @@ -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: diff --git a/dfjkl b/dfjkl deleted file mode 100644 index 8bd8955..0000000 --- a/dfjkl +++ /dev/null @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 9eca656..a8134bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: