Compare commits
4 Commits
ff438c1197
...
1.0-experi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b9d51151a | ||
|
|
7715123053 | ||
|
|
94354da611 | ||
|
|
5b907c0cd7 |
91
CHANGELOG.md
91
CHANGELOG.md
@@ -1,10 +1,99 @@
|
||||
# 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/),
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
30
README.md
30
README.md
@@ -1,5 +1,31 @@
|
||||
# Seismo Fleet Manager v0.4.2
|
||||
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.
|
||||
# Terra-View v0.5.0
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
11
app/main.py
11
app/main.py
@@ -34,11 +34,13 @@ from app.seismo.routers import (
|
||||
activity as seismo_activity,
|
||||
seismo_dashboard as seismo_seismo_dashboard,
|
||||
settings as seismo_settings,
|
||||
partials as seismo_partials,
|
||||
)
|
||||
from app.seismo import routes as seismo_legacy_routes
|
||||
|
||||
# Import feature module routers (SLM)
|
||||
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)
|
||||
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
|
||||
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
|
||||
app = FastAPI(
|
||||
title=APP_NAME,
|
||||
@@ -100,10 +105,16 @@ app.include_router(seismo_dashboard_tabs.router)
|
||||
app.include_router(seismo_activity.router)
|
||||
app.include_router(seismo_seismo_dashboard.router)
|
||||
app.include_router(seismo_settings.router)
|
||||
app.include_router(seismo_partials.router, prefix="/partials")
|
||||
app.include_router(seismo_legacy_routes.router)
|
||||
|
||||
# SLM Feature Module APIs
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from app.seismo.services.snapshot import emit_status_snapshot
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
templates = Jinja2Templates(directory="app/ui/templates")
|
||||
|
||||
|
||||
@router.get("/dashboard/active")
|
||||
|
||||
140
app/seismo/routers/partials.py
Normal file
140
app/seismo/routers/partials.py
Normal 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
|
||||
}
|
||||
)
|
||||
@@ -11,7 +11,7 @@ from app.seismo.database import get_db
|
||||
from app.seismo.models import RosterUnit
|
||||
|
||||
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)
|
||||
|
||||
317
app/slm/dashboard.py
Normal file
317
app/slm/dashboard.py
Normal 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)
|
||||
}
|
||||
@@ -568,9 +568,13 @@ function initLiveDataStream(unitId) {
|
||||
if (stopBtn) stopBtn.style.display = 'flex';
|
||||
};
|
||||
|
||||
window.currentWebSocket.onmessage = function(event) {
|
||||
window.currentWebSocket.onmessage = async function(event) {
|
||||
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);
|
||||
updateLiveMetrics(data);
|
||||
updateLiveChart(data);
|
||||
|
||||
@@ -167,10 +167,18 @@ function initLiveDataStream(unitId) {
|
||||
if (stopBtn) stopBtn.style.display = 'flex';
|
||||
};
|
||||
|
||||
currentWebSocket.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
currentWebSocket.onmessage = async function(event) {
|
||||
try {
|
||||
let payload = event.data;
|
||||
if (payload instanceof Blob) {
|
||||
payload = await payload.text();
|
||||
}
|
||||
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
||||
updateLiveChart(data);
|
||||
updateLiveMetrics(data);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
currentWebSocket.onerror = function(error) {
|
||||
|
||||
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