4 Commits

Author SHA1 Message Date
serversdwn
7b9d51151a WIP: 1.0 architecture experiment (pre-stabilization) 2026-01-09 21:49:26 +00:00
serversdwn
7715123053 architecture: remove redundant SFM service and simplify deployment 2026-01-09 20:58:16 +00:00
serversdwn
94354da611 seismo fleet roster repaired and visible. 2026-01-09 20:02:05 +00:00
serversdwn
5b907c0cd7 Migration cleanup: SLM dashboard restored, db migration 2026-01-09 19:14:09 +00:00
13 changed files with 696 additions and 84 deletions

View File

@@ -1,10 +1,99 @@
# Changelog # Changelog
All notable changes to Seismo Fleet Manager will be documented in this file. All notable changes to Terra-View will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.0] - 2026-01-09
### Added
- **Unified Modular Monolith Architecture**: Complete architectural refactoring to modular monolith pattern
- **Three Feature Modules**: Seismo (seismograph fleet), SLM (sound level meters), UI (shared templates/static)
- **Module Isolation**: Each module has its own database, models, services, and routers
- **Shared Infrastructure**: Common utilities and API aggregation layer
- **Multi-Container Deployment**: Three Docker containers (terra-view, sfm, slmm) built from single codebase
- **SLMM Integration**: Sound Level Meter Manager fully integrated as `app/slm/` module
- Migrated from separate repository to unified codebase
- Complete NL43 device management API (`/api/nl43/*`)
- Database models for NL43Config and NL43Status
- NL43Client service for device communication
- FTP, TCP, and web interface support for NL43 devices
- **SLM Dashboard API Layer**: New dashboard endpoints bridge UI and device APIs
- `GET /api/slm-dashboard/stats` - Aggregate statistics (total units, online/offline, measuring/idle)
- `GET /api/slm-dashboard/units` - List all units with latest status
- `GET /api/slm-dashboard/live-view/{unit_id}` - Real-time measurement data
- `GET /api/slm-dashboard/config/{unit_id}` - Retrieve unit configuration
- `POST /api/slm-dashboard/config/{unit_id}` - Update unit configuration
- `POST /api/slm-dashboard/control/{unit_id}/{action}` - Send control commands (start, stop, pause, resume, reset, sleep, wake)
- `GET /api/slm-dashboard/test-modem/{unit_id}` - Test device connectivity
- **Repository Rebranding**: Renamed from `seismo-fleet-manager` to `terra-view`
- Reflects unified platform nature (seismo + SLM + future modules)
- Git remote updated to `terra-view.git`
- All references updated throughout codebase
### Changed
- **Project Structure**: Complete reorganization following modular monolith pattern
- `app/seismo/` - Seismograph fleet module (formerly `backend/`)
- `app/slm/` - Sound level meter module (integrated from SLMM)
- `app/ui/` - Shared templates and static assets
- `app/api/` - Cross-module API aggregation layer
- Removed `backend/` and `templates/` directories
- **Import Paths**: All imports updated from `backend.*` to `app.seismo.*` or `app.slm.*`
- **Database Initialization**: Each module initializes its own database tables
- Seismo database: `app/seismo/database.py`
- SLM database: `app/slm/database.py`
- **Docker Architecture**: Three-container deployment from single codebase
- `terra-view` (port 8001): Main UI/orchestrator with all modules
- `sfm` (port 8002): Seismograph Fleet Module API
- `slmm` (port 8100): Sound Level Meter Manager API
- All containers built from same unified codebase with different entry points
### Fixed
- **Template Path Issues**: Fixed seismo dashboard template references
- Updated `app/seismo/routers/dashboard.py` to use `app/ui/templates` directory
- Resolved 404 errors for `partials/benched_table.html` and `partials/active_table.html`
- **Module Import Errors**: Corrected SLMM module structure
- Fixed `app/slm/main.py` to import from `app.slm.routers` instead of `app.routers`
- Updated all SLMM internal imports to use `app.slm.*` namespace
- **Docker Build Issues**: Resolved file permission problems
- Fixed dashboard.py permissions for Docker COPY operations
- Ensured all source files readable during container builds
### Technical Details
- **Modular Monolith Benefits**:
- Single repository for easier development and deployment
- Module boundaries enforced through folder structure
- Shared dependencies managed in single requirements.txt
- Independent database schemas per module
- Clean separation of concerns with explicit module APIs
- **Migration Path**: Existing installations automatically migrate
- Import path updates applied programmatically
- Database schemas remain compatible
- No data migration required
- **Module Structure**: Each module follows consistent pattern
- `database.py` - SQLAlchemy models and session management
- `models.py` - Pydantic schemas and database models
- `routers.py` - FastAPI route definitions
- `services.py` - Business logic and external integrations
- **Container Communication**: Containers use host networking
- terra-view proxies to sfm and slmm containers
- Environment variables configure API URLs
- Health checks ensure container availability
### Migration Notes
- **Breaking Changes**: Import paths changed for all modules
- Old: `from backend.models import RosterUnit`
- New: `from app.seismo.models import RosterUnit`
- **Configuration Updates**: Environment variables for multi-container setup
- `SFM_API_URL=http://localhost:8002` - SFM backend endpoint
- `SLMM_API_URL=http://localhost:8100` - SLMM backend endpoint
- `MODULE_MODE=sfm|slmm` - Future flag for API-only containers
- **Repository Migration**: Update git remotes for renamed repository
```bash
git remote set-url origin ssh://git@10.0.0.2:2222/serversdown/terra-view.git
```
## [0.4.2] - 2026-01-05 ## [0.4.2] - 2026-01-05
### Added ### Added

View File

@@ -1,5 +1,31 @@
# Seismo Fleet Manager v0.4.2 # Terra-View v0.5.0
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. Unified platform for managing seismograph fleets and sound level meter deployments. Built as a modular monolith with independent feature modules (Seismo, SLM) sharing a common UI layer. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your entire fleet through a unified database and dashboard.
## Architecture
Terra-View follows a **modular monolith** architecture with independent feature modules in a single codebase:
- **app/seismo/** - Seismograph Fleet Module (SFM)
- Device roster and deployment tracking
- Series 3/4 telemetry ingestion
- Status monitoring (OK/Pending/Missing)
- Photo management and location tracking
- **app/slm/** - Sound Level Meter Manager (SLMM)
- NL43 device configuration and control
- Real-time measurement monitoring
- TCP/FTP/Web interface support
- Dashboard statistics and unit management
- **app/ui/** - Shared UI layer
- Templates, static assets, and common components
- Progressive Web App (PWA) support
- **app/api/** - API aggregation layer
- Cross-module endpoints
- Future unified dashboard APIs
**Multi-Container Deployment**: Three Docker containers built from the same codebase:
- `terra-view` (port 8001) - Main UI with all modules integrated
- `sfm` (port 8002) - Seismo API backend
- `slmm` (port 8100) - SLM API backend
## Features ## Features

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

@@ -34,11 +34,13 @@ from app.seismo.routers import (
activity as seismo_activity, activity as seismo_activity,
seismo_dashboard as seismo_seismo_dashboard, seismo_dashboard as seismo_seismo_dashboard,
settings as seismo_settings, settings as seismo_settings,
partials as seismo_partials,
) )
from app.seismo import routes as seismo_legacy_routes from app.seismo import routes as seismo_legacy_routes
# Import feature module routers (SLM) # Import feature module routers (SLM)
from app.slm.routers import router as slm_router from app.slm.routers import router as slm_router
from app.slm.dashboard import router as slm_dashboard_router
# Import API aggregation layer (placeholder for now) # Import API aggregation layer (placeholder for now)
from app.api import dashboard as api_dashboard from app.api import dashboard as api_dashboard
@@ -48,6 +50,9 @@ from app.api import roster as api_roster
from app.seismo.database import engine as seismo_engine, Base as SeismoBase from app.seismo.database import engine as seismo_engine, Base as SeismoBase
SeismoBase.metadata.create_all(bind=seismo_engine) SeismoBase.metadata.create_all(bind=seismo_engine)
from app.slm.database import engine as slm_engine, Base as SlmBase
SlmBase.metadata.create_all(bind=slm_engine)
# Initialize FastAPI app # Initialize FastAPI app
app = FastAPI( app = FastAPI(
title=APP_NAME, title=APP_NAME,
@@ -100,10 +105,16 @@ app.include_router(seismo_dashboard_tabs.router)
app.include_router(seismo_activity.router) app.include_router(seismo_activity.router)
app.include_router(seismo_seismo_dashboard.router) app.include_router(seismo_seismo_dashboard.router)
app.include_router(seismo_settings.router) app.include_router(seismo_settings.router)
app.include_router(seismo_partials.router, prefix="/partials")
app.include_router(seismo_legacy_routes.router) app.include_router(seismo_legacy_routes.router)
# SLM Feature Module APIs # SLM Feature Module APIs
app.include_router(slm_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) # 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

View File

@@ -4,7 +4,7 @@ from fastapi.templating import Jinja2Templates
from app.seismo.services.snapshot import emit_status_snapshot from app.seismo.services.snapshot import emit_status_snapshot
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="app/ui/templates")
@router.get("/dashboard/active") @router.get("/dashboard/active")

View File

@@ -0,0 +1,140 @@
"""
Partial routes for HTMX dynamic content loading.
These routes return HTML fragments that are loaded into the page via HTMX.
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.seismo.services.snapshot import emit_status_snapshot
router = APIRouter()
templates = Jinja2Templates(directory="app/ui/templates")
@router.get("/unknown-emitters", response_class=HTMLResponse)
async def get_unknown_emitters(request: Request):
"""
Returns HTML partial with unknown emitters (units reporting but not in roster).
Called periodically via HTMX (every 10s) from the roster page.
"""
snapshot = emit_status_snapshot()
# Convert unknown units dict to list and add required fields
unknown_list = []
for unit_id, unit_data in snapshot.get("unknown", {}).items():
unknown_list.append({
"id": unit_id,
"status": unit_data["status"],
"age": unit_data["age"],
"fname": unit_data.get("fname", ""),
})
# Sort by ID for consistent display
unknown_list.sort(key=lambda x: x["id"])
return templates.TemplateResponse(
"partials/unknown_emitters.html",
{
"request": request,
"unknown_units": unknown_list
}
)
@router.get("/devices-all", response_class=HTMLResponse)
async def get_all_devices(request: Request):
"""
Returns HTML partial with all devices (deployed, benched, retired, ignored).
Called on page load and when filters are applied.
"""
snapshot = emit_status_snapshot()
# Combine all units from different buckets
all_units = []
# Add active units (deployed)
for unit_id, unit_data in snapshot.get("active", {}).items():
unit_info = {
"id": unit_id,
"status": unit_data["status"],
"age": unit_data["age"],
"last_seen": unit_data.get("last", ""),
"fname": unit_data.get("fname", ""),
"deployed": True,
"retired": False,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
"location": unit_data.get("location", ""),
"address": unit_data.get("address", ""),
"coordinates": unit_data.get("coordinates", ""),
"last_calibrated": unit_data.get("last_calibrated"),
"next_calibration_due": unit_data.get("next_calibration_due"),
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
"ip_address": unit_data.get("ip_address"),
"phone_number": unit_data.get("phone_number"),
"hardware_model": unit_data.get("hardware_model"),
}
all_units.append(unit_info)
# Add benched units (not deployed, not retired)
for unit_id, unit_data in snapshot.get("benched", {}).items():
unit_info = {
"id": unit_id,
"status": unit_data["status"],
"age": unit_data["age"],
"last_seen": unit_data.get("last", ""),
"fname": unit_data.get("fname", ""),
"deployed": False,
"retired": False,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
"location": unit_data.get("location", ""),
"address": unit_data.get("address", ""),
"coordinates": unit_data.get("coordinates", ""),
"last_calibrated": unit_data.get("last_calibrated"),
"next_calibration_due": unit_data.get("next_calibration_due"),
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
"ip_address": unit_data.get("ip_address"),
"phone_number": unit_data.get("phone_number"),
"hardware_model": unit_data.get("hardware_model"),
}
all_units.append(unit_info)
# Add retired units
for unit_id, unit_data in snapshot.get("retired", {}).items():
unit_info = {
"id": unit_id,
"status": "Retired",
"age": unit_data["age"],
"last_seen": unit_data.get("last", ""),
"fname": unit_data.get("fname", ""),
"deployed": False,
"retired": True,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
"location": unit_data.get("location", ""),
"address": unit_data.get("address", ""),
"coordinates": unit_data.get("coordinates", ""),
"last_calibrated": unit_data.get("last_calibrated"),
"next_calibration_due": unit_data.get("next_calibration_due"),
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
"ip_address": unit_data.get("ip_address"),
"phone_number": unit_data.get("phone_number"),
"hardware_model": unit_data.get("hardware_model"),
}
all_units.append(unit_info)
# Sort by ID for consistent display
all_units.sort(key=lambda x: x["id"])
return templates.TemplateResponse(
"partials/devices_table.html",
{
"request": request,
"units": all_units
}
)

View File

@@ -11,7 +11,7 @@ from app.seismo.database import get_db
from app.seismo.models import RosterUnit from app.seismo.models import RosterUnit
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"]) router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="app/ui/templates")
@router.get("/stats", response_class=HTMLResponse) @router.get("/stats", response_class=HTMLResponse)

317
app/slm/dashboard.py Normal file
View File

@@ -0,0 +1,317 @@
"""
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, 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 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", 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()
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)
# 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)
# 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", 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 slm in slms:
# Map to template field names
unit_data = {
"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 templates.TemplateResponse(
"partials/slm_unit_list.html",
{
"request": request,
"units": units
}
)
@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}", response_class=HTMLResponse)
async def get_unit_config(unit_id: str, request: Request, roster_db: Session = Depends(get_seismo_db)):
"""Return the HTML config form for a specific unit."""
unit = roster_db.query(RosterUnit).filter_by(
id=unit_id,
device_type="sound_level_meter"
).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit configuration not found")
return templates.TemplateResponse(
"partials/slm_config_form.html",
{
"request": request,
"unit": unit
}
)
@router.post("/config/{unit_id}")
async def update_unit_config(
unit_id: str,
request: Request,
roster_db: Session = Depends(get_seismo_db),
slm_db: Session = Depends(get_slm_db)
):
"""Update configuration for a specific unit from the form submission."""
unit = roster_db.query(RosterUnit).filter_by(
id=unit_id,
device_type="sound_level_meter"
).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit configuration not found")
form = await request.form()
def get_int(value, default=None):
try:
return int(value) if value not in (None, "") else default
except (TypeError, ValueError):
return default
# Update roster fields
unit.slm_model = form.get("slm_model") or unit.slm_model
unit.slm_serial_number = form.get("slm_serial_number") or unit.slm_serial_number
unit.slm_frequency_weighting = form.get("slm_frequency_weighting") or unit.slm_frequency_weighting
unit.slm_time_weighting = form.get("slm_time_weighting") or unit.slm_time_weighting
unit.slm_measurement_range = form.get("slm_measurement_range") or unit.slm_measurement_range
unit.slm_host = form.get("slm_host") or None
unit.slm_tcp_port = get_int(form.get("slm_tcp_port"), unit.slm_tcp_port or 2255)
unit.slm_ftp_port = get_int(form.get("slm_ftp_port"), unit.slm_ftp_port or 21)
deployed_with_modem_id = form.get("deployed_with_modem_id") or None
unit.deployed_with_modem_id = deployed_with_modem_id
roster_db.commit()
roster_db.refresh(unit)
# Update or create NL43 config so SLMM can reach the device
config = slm_db.query(NL43Config).filter_by(unit_id=unit_id).first()
if not config:
config = NL43Config(unit_id=unit_id)
slm_db.add(config)
# Resolve host from modem if present, otherwise fall back to direct IP or existing config
host_for_config = None
if deployed_with_modem_id:
modem = roster_db.query(RosterUnit).filter_by(
id=deployed_with_modem_id,
device_type="modem"
).first()
if modem and modem.ip_address:
host_for_config = modem.ip_address
if not host_for_config:
host_for_config = unit.slm_host or config.host or "127.0.0.1"
config.host = host_for_config
config.tcp_port = get_int(form.get("slm_tcp_port"), config.tcp_port or 2255)
config.tcp_enabled = True
config.ftp_enabled = bool(config.ftp_username and config.ftp_password)
slm_db.commit()
slm_db.refresh(config)
return {"success": True, "unit_id": unit_id}
@router.post("/control/{unit_id}/{action}")
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:
raise HTTPException(status_code=404, detail="Unit configuration not found")
if not config.tcp_enabled:
raise HTTPException(status_code=400, detail="TCP control not enabled for this unit")
# Create NL43Client
client = NL43Client(
host=config.host,
port=config.tcp_port,
timeout=5.0,
ftp_username=config.ftp_username,
ftp_password=config.ftp_password
)
# Map action to command
action_map = {
"start": "start_measurement",
"stop": "stop_measurement",
"pause": "pause_measurement",
"resume": "resume_measurement",
"reset": "reset_measurement",
"sleep": "sleep_mode",
"wake": "wake_from_sleep",
}
if action not in action_map:
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
method_name = action_map[action]
method = getattr(client, method_name, None)
if not method:
raise HTTPException(status_code=500, detail=f"Method {method_name} not implemented")
try:
result = await method()
return {"success": True, "action": action, "result": result}
except Exception as e:
logger.error(f"Error executing {action} on {unit_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/test-modem/{unit_id}")
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:
raise HTTPException(status_code=404, detail="Unit configuration not found")
if not config.tcp_enabled:
raise HTTPException(status_code=400, detail="TCP control not enabled for this unit")
client = NL43Client(
host=config.host,
port=config.tcp_port,
timeout=5.0,
ftp_username=config.ftp_username,
ftp_password=config.ftp_password
)
try:
# Try to get measurement state as a connectivity test
state = await client.get_measurement_state()
return {
"success": True,
"unit_id": unit_id,
"host": config.host,
"port": config.tcp_port,
"reachable": True,
"measurement_state": state
}
except Exception as e:
logger.warning(f"Modem test failed for {unit_id}: {e}")
return {
"success": False,
"unit_id": unit_id,
"host": config.host,
"port": config.tcp_port,
"reachable": False,
"error": str(e)
}

View File

@@ -568,9 +568,13 @@ function initLiveDataStream(unitId) {
if (stopBtn) stopBtn.style.display = 'flex'; if (stopBtn) stopBtn.style.display = 'flex';
}; };
window.currentWebSocket.onmessage = function(event) { window.currentWebSocket.onmessage = async function(event) {
try { try {
const data = JSON.parse(event.data); let payload = event.data;
if (payload instanceof Blob) {
payload = await payload.text();
}
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
console.log('WebSocket data received:', data); console.log('WebSocket data received:', data);
updateLiveMetrics(data); updateLiveMetrics(data);
updateLiveChart(data); updateLiveChart(data);

View File

@@ -167,10 +167,18 @@ function initLiveDataStream(unitId) {
if (stopBtn) stopBtn.style.display = 'flex'; if (stopBtn) stopBtn.style.display = 'flex';
}; };
currentWebSocket.onmessage = function(event) { currentWebSocket.onmessage = async function(event) {
const data = JSON.parse(event.data); try {
let payload = event.data;
if (payload instanceof Blob) {
payload = await payload.text();
}
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
updateLiveChart(data); updateLiveChart(data);
updateLiveMetrics(data); updateLiveMetrics(data);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
}; };
currentWebSocket.onerror = function(error) { currentWebSocket.onerror = function(error) {

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: