Compare commits
3 Commits
v0.4.2
...
b9a3da8487
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9a3da8487 | ||
|
|
e1b965c24c | ||
|
|
ee025f1f34 |
@@ -35,7 +35,7 @@ data/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.claude
|
.claude
|
||||||
sfm.code-workspace
|
terra-view.code-workspace
|
||||||
|
|
||||||
# Tests (optional)
|
# Tests (optional)
|
||||||
tests/
|
tests/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 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).
|
||||||
@@ -54,7 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [0.4.1] - 2026-01-05
|
## [0.4.1] - 2026-01-05
|
||||||
### Added
|
### Added
|
||||||
- **SLM Integration**: Sound Level Meters are now manageable in SFM
|
- **SLM Integration**: Sound Level Meters are now manageable in Terra-View
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fixed an issue where unit status was loading from a saved cache and not based on when it was actually heard from last. Unit status is now accurate.
|
- Fixed an issue where unit status was loading from a saved cache and not based on when it was actually heard from last. Unit status is now accurate.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Seismo Fleet Manager v0.4.2
|
# Terra-View 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.
|
Backend API and HTMX-powered web interface for Terra-View - a unified fleet management system. 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 supports seismographs (SFM module), sound level meters, field modems, and other monitoring devices.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
|||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.4.2"
|
VERSION = "0.4.2"
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Seismo Fleet Manager",
|
title="Terra-View",
|
||||||
description="Backend API for managing seismograph fleet status",
|
description="Backend API for Terra-View fleet management system",
|
||||||
version=VERSION
|
version=VERSION
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -516,7 +516,7 @@ async def devices_all_partial(request: Request):
|
|||||||
def health_check():
|
def health_check():
|
||||||
"""Health check endpoint"""
|
"""Health check endpoint"""
|
||||||
return {
|
return {
|
||||||
"message": f"Seismo Fleet Manager v{VERSION}",
|
"message": f"Terra-View v{VERSION}",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"version": VERSION
|
"version": VERSION
|
||||||
}
|
}
|
||||||
@@ -524,4 +524,6 @@ def health_check():
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
import os
|
||||||
|
port = int(os.getenv("PORT", 8001))
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Provides API endpoints for the Sound Level Meters dashboard page.
|
|||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends, Query
|
from fastapi import APIRouter, Request, Depends, Query
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -60,14 +60,20 @@ async def get_slm_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
async def get_slm_units(
|
async def get_slm_units(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
search: str = Query(None)
|
search: str = Query(None),
|
||||||
|
project: str = Query(None)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get list of SLM units for the sidebar.
|
Get list of SLM units for the sidebar.
|
||||||
Returns HTML partial with unit cards.
|
Returns HTML partial with unit cards.
|
||||||
|
Supports filtering by search term and project.
|
||||||
"""
|
"""
|
||||||
query = db.query(RosterUnit).filter_by(device_type="sound_level_meter")
|
query = db.query(RosterUnit).filter_by(device_type="sound_level_meter")
|
||||||
|
|
||||||
|
# Filter by project if provided
|
||||||
|
if project:
|
||||||
|
query = query.filter(RosterUnit.project_id == project)
|
||||||
|
|
||||||
# Filter by search term if provided
|
# Filter by search term if provided
|
||||||
if search:
|
if search:
|
||||||
search_term = f"%{search}%"
|
search_term = f"%{search}%"
|
||||||
@@ -326,3 +332,55 @@ async def test_modem_connection(modem_id: str, db: Session = Depends(get_db)):
|
|||||||
"modem_id": modem_id,
|
"modem_id": modem_id,
|
||||||
"detail": str(e)
|
"detail": str(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/diagnostics/{unit_id}", response_class=HTMLResponse)
|
||||||
|
async def get_diagnostics(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get compact diagnostics card for a specific SLM unit.
|
||||||
|
Returns HTML partial with key metrics only.
|
||||||
|
"""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first()
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
return HTMLResponse(
|
||||||
|
content='<div class="p-6 text-center text-red-600">Unit not found</div>',
|
||||||
|
status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get modem info
|
||||||
|
modem = None
|
||||||
|
modem_ip = None
|
||||||
|
if unit.deployed_with_modem_id:
|
||||||
|
modem = db.query(RosterUnit).filter_by(id=unit.deployed_with_modem_id, device_type="modem").first()
|
||||||
|
if modem:
|
||||||
|
# Try modem_rx_host first (if it exists), then fall back to ip_address
|
||||||
|
modem_ip = getattr(modem, 'modem_rx_host', None) or modem.ip_address
|
||||||
|
elif unit.slm_host:
|
||||||
|
modem_ip = unit.slm_host
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/slm_diagnostics_card.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit": unit,
|
||||||
|
"modem": modem,
|
||||||
|
"modem_ip": modem_ip
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects")
|
||||||
|
async def get_projects(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get list of unique projects from deployed SLMs.
|
||||||
|
Returns JSON array of project names.
|
||||||
|
"""
|
||||||
|
projects = db.query(RosterUnit.project_id).filter(
|
||||||
|
RosterUnit.device_type == "sound_level_meter",
|
||||||
|
RosterUnit.deployed == True,
|
||||||
|
RosterUnit.retired == False,
|
||||||
|
RosterUnit.project_id.isnot(None)
|
||||||
|
).distinct().order_by(RosterUnit.project_id).all()
|
||||||
|
|
||||||
|
# Extract project names from query result tuples
|
||||||
|
project_list = [p[0] for p in projects if p[0]]
|
||||||
|
|
||||||
|
return JSONResponse(content={"projects": project_list})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
SLMM (Sound Level Meter Manager) Proxy Router
|
SLMM (Sound Level Meter Manager) Proxy Router
|
||||||
|
|
||||||
Proxies requests from SFM to the standalone SLMM backend service.
|
Proxies requests from Terra-View to the standalone SLMM backend service.
|
||||||
SLMM runs on port 8100 and handles NL43/NL53 sound level meter communication.
|
SLMM runs on port 8100 and handles NL43/NL53 sound level meter communication.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ async def proxy_websocket_stream(websocket: WebSocket, unit_id: str):
|
|||||||
Proxy WebSocket connections to SLMM's /stream endpoint.
|
Proxy WebSocket connections to SLMM's /stream endpoint.
|
||||||
|
|
||||||
This allows real-time streaming of measurement data from NL43 devices
|
This allows real-time streaming of measurement data from NL43 devices
|
||||||
through the SFM unified interface.
|
through the Terra-View unified interface.
|
||||||
"""
|
"""
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
logger.info(f"WebSocket connection accepted for SLMM unit {unit_id}")
|
logger.info(f"WebSocket connection accepted for SLMM unit {unit_id}")
|
||||||
@@ -237,7 +237,7 @@ async def proxy_to_slmm(path: str, request: Request):
|
|||||||
"""
|
"""
|
||||||
Proxy all requests to the SLMM backend service.
|
Proxy all requests to the SLMM backend service.
|
||||||
|
|
||||||
This allows SFM to act as a unified frontend for all device types,
|
This allows Terra-View to act as a unified frontend for all device types,
|
||||||
while SLMM remains a standalone backend service.
|
while SLMM remains a standalone backend service.
|
||||||
"""
|
"""
|
||||||
# Build target URL
|
# Build target URL
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/* IndexedDB wrapper for offline data storage in SFM */
|
/* IndexedDB wrapper for offline data storage in Terra-View */
|
||||||
/* Handles unit data, status snapshots, and pending edit queue */
|
/* Handles unit data, status snapshots, and pending edit queue */
|
||||||
|
|
||||||
class OfflineDB {
|
class OfflineDB {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.dbName = 'sfm-offline-db';
|
this.dbName = 'terra-view-offline-db';
|
||||||
this.version = 1;
|
this.version = 1;
|
||||||
this.db = null;
|
this.db = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/* Service Worker for Seismo Fleet Manager PWA */
|
/* Service Worker for Terra-View PWA */
|
||||||
/* Network-first strategy with cache fallback for real-time data */
|
/* Network-first strategy with cache fallback for real-time data */
|
||||||
|
|
||||||
const CACHE_VERSION = 'v1';
|
const CACHE_VERSION = 'v1';
|
||||||
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
|
const STATIC_CACHE = `terra-view-static-${CACHE_VERSION}`;
|
||||||
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
|
const DYNAMIC_CACHE = `terra-view-dynamic-${CACHE_VERSION}`;
|
||||||
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
|
const DATA_CACHE = `terra-view-data-${CACHE_VERSION}`;
|
||||||
|
|
||||||
// Files to precache (critical app shell)
|
// Files to precache (critical app shell)
|
||||||
const STATIC_FILES = [
|
const STATIC_FILES = [
|
||||||
@@ -137,7 +137,7 @@ async function networkFirstStrategy(request, cacheName) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Offline - SFM</title>
|
<title>Offline - Terra-View</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
@@ -170,7 +170,7 @@ async function networkFirstStrategy(request, cacheName) {
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>📡 You're Offline</h1>
|
<h1>📡 You're Offline</h1>
|
||||||
<p>SFM requires an internet connection for this page.</p>
|
<p>Terra-View requires an internet connection for this page.</p>
|
||||||
<p>Please check your connection and try again.</p>
|
<p>Please check your connection and try again.</p>
|
||||||
<button onclick="location.reload()">Retry</button>
|
<button onclick="location.reload()">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,7 +285,7 @@ async function syncPendingEdits() {
|
|||||||
// IndexedDB helpers (simplified versions - full implementations in offline-db.js)
|
// IndexedDB helpers (simplified versions - full implementations in offline-db.js)
|
||||||
function openDatabase() {
|
function openDatabase() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open('sfm-offline-db', 1);
|
const request = indexedDB.open('terra-view-offline-db', 1);
|
||||||
|
|
||||||
request.onerror = () => reject(request.error);
|
request.onerror = () => reject(request.error);
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ services:
|
|||||||
terra-view-prod:
|
terra-view-prod:
|
||||||
build: .
|
build: .
|
||||||
container_name: terra-view
|
container_name: terra-view
|
||||||
ports:
|
network_mode: host
|
||||||
- "8001:8001"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=production
|
||||||
- SLMM_BASE_URL=http://slmm:8100
|
- PORT=8001
|
||||||
|
- SLMM_BASE_URL=http://localhost:8100
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- slmm
|
- slmm
|
||||||
@@ -26,19 +26,21 @@ services:
|
|||||||
terra-view-dev:
|
terra-view-dev:
|
||||||
build: .
|
build: .
|
||||||
container_name: terra-view-dev
|
container_name: terra-view-dev
|
||||||
ports:
|
network_mode: host
|
||||||
- "1001:8001"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data-dev:/app/data
|
- ./data-dev:/app/data
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- ENVIRONMENT=development
|
- ENVIRONMENT=development
|
||||||
- SLMM_BASE_URL=http://slmm:8100
|
- PORT=1001
|
||||||
|
- SLMM_BASE_URL=http://localhost:8100
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- slmm
|
- slmm
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:1001/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -50,8 +52,7 @@ services:
|
|||||||
context: ../../slmm
|
context: ../../slmm
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: slmm
|
container_name: slmm
|
||||||
ports:
|
network_mode: host
|
||||||
- "8100:8100"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ../../slmm/data:/app/data
|
- ../../slmm/data:/app/data
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
206
templates/partials/slm_diagnostics_card.html
Normal file
206
templates/partials/slm_diagnostics_card.html
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<!-- Compact Diagnostics Card for {{ unit.id }} -->
|
||||||
|
<div class="h-full flex flex-col p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ unit.id }}</h2>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{% if unit.slm_model %}{{ unit.slm_model }}{% endif %}
|
||||||
|
{% if unit.slm_serial_number %} • S/N: {{ unit.slm_serial_number }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Badge -->
|
||||||
|
<span id="diag-status-badge" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium">
|
||||||
|
Loading...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection Status -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 mb-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 text-gray-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M5.05 3.636a1 1 0 010 1.414 7 7 0 000 9.9 1 1 0 11-1.414 1.414 9 9 0 010-12.728 1 1 0 011.414 0zm9.9 0a1 1 0 011.414 0 9 9 0 010 12.728 1 1 0 11-1.414-1.414 7 7 0 000-9.9 1 1 0 010-1.414zM7.879 6.464a1 1 0 010 1.414 3 3 0 000 4.243 1 1 0 11-1.415 1.414 5 5 0 010-7.07 1 1 0 011.415 0zm4.242 0a1 1 0 011.415 0 5 5 0 010 7.072 1 1 0 01-1.415-1.415 3 3 0 000-4.242 1 1 0 010-1.415zM10 9a1 1 0 011 1v.01a1 1 0 11-2 0V10a1 1 0 011-1z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Connection</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if modem %}
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">via {{ modem.id }}</span>
|
||||||
|
{% elif modem_ip %}
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Direct: {{ modem_ip }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-sm text-red-600 dark:text-red-400">Not configured</span>
|
||||||
|
{% endif %}
|
||||||
|
<span id="connection-status" class="ml-2 w-2 h-2 bg-gray-400 rounded-full inline-block"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Sound Levels -->
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</p>
|
||||||
|
<p id="diag-lp" class="text-2xl font-bold text-blue-600 dark:text-blue-400">--</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</p>
|
||||||
|
<p id="diag-leq" class="text-2xl font-bold text-green-600 dark:text-green-400">--</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmax (Max)</p>
|
||||||
|
<p id="diag-lmax" class="text-2xl font-bold text-red-600 dark:text-red-400">--</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
|
||||||
|
<p id="diag-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Battery and Power -->
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">Battery</span>
|
||||||
|
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V4a2 2 0 00-2-2H6zm0 2h8v12H6V4zm7 2a1 1 0 011 1v6a1 1 0 11-2 0V7a1 1 0 011-1z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div id="diag-battery-level" class="text-xl font-bold text-gray-900 dark:text-white">--</div>
|
||||||
|
<div class="mt-2 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div id="diag-battery-bar" class="bg-gray-400 h-2 rounded-full transition-all" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">Power</span>
|
||||||
|
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div id="diag-power-source" class="text-lg font-semibold text-gray-900 dark:text-white">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Check-in -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 mb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 text-gray-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Last Check-in</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if unit.slm_last_check %}
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{ unit.slm_last_check.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-500">Never</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Open Command Center Button -->
|
||||||
|
<div class="mt-auto">
|
||||||
|
<button onclick="openCommandCenter('{{ unit.id }}')"
|
||||||
|
class="w-full px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium flex items-center justify-center transition-colors">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
||||||
|
</svg>
|
||||||
|
Open Command Center
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const diagUnitId = '{{ unit.id }}';
|
||||||
|
|
||||||
|
// Clear any existing connections before starting new ones
|
||||||
|
window.SLMConnectionManager.setCurrentUnit(diagUnitId);
|
||||||
|
|
||||||
|
function updateDiagnosticsData() {
|
||||||
|
fetch(`/api/slmm/${diagUnitId}/live`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.status === 'ok' && result.data) {
|
||||||
|
const data = result.data;
|
||||||
|
|
||||||
|
// Update status badge
|
||||||
|
const statusBadge = document.getElementById('diag-status-badge');
|
||||||
|
if (statusBadge) {
|
||||||
|
const isMeasuring = data.measurement_state === 'Start';
|
||||||
|
if (isMeasuring) {
|
||||||
|
statusBadge.className = 'px-4 py-2 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 rounded-lg font-medium flex items-center';
|
||||||
|
statusBadge.innerHTML = '<span class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>Measuring';
|
||||||
|
} else {
|
||||||
|
statusBadge.className = 'px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium';
|
||||||
|
statusBadge.textContent = 'Stopped';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sound levels
|
||||||
|
['lp', 'leq', 'lmax', 'lmin'].forEach(metric => {
|
||||||
|
const el = document.getElementById(`diag-${metric}`);
|
||||||
|
if (el) el.textContent = data[metric] || '--';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update battery
|
||||||
|
const batteryEl = document.getElementById('diag-battery-level');
|
||||||
|
const batteryBar = document.getElementById('diag-battery-bar');
|
||||||
|
if (batteryEl && data.battery_level) {
|
||||||
|
const level = parseInt(data.battery_level);
|
||||||
|
batteryEl.textContent = `${level}%`;
|
||||||
|
|
||||||
|
if (batteryBar) {
|
||||||
|
batteryBar.style.width = `${level}%`;
|
||||||
|
if (level > 50) {
|
||||||
|
batteryBar.className = 'bg-green-500 h-2 rounded-full transition-all';
|
||||||
|
} else if (level > 20) {
|
||||||
|
batteryBar.className = 'bg-yellow-500 h-2 rounded-full transition-all';
|
||||||
|
} else {
|
||||||
|
batteryBar.className = 'bg-red-500 h-2 rounded-full transition-all';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update power source
|
||||||
|
const powerEl = document.getElementById('diag-power-source');
|
||||||
|
if (powerEl) powerEl.textContent = data.power_source || '--';
|
||||||
|
|
||||||
|
// Update connection status
|
||||||
|
const connStatus = document.getElementById('connection-status');
|
||||||
|
if (connStatus) {
|
||||||
|
connStatus.className = 'ml-2 w-2 h-2 bg-green-500 rounded-full inline-block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Failed to refresh diagnostics:', error);
|
||||||
|
const connStatus = document.getElementById('connection-status');
|
||||||
|
if (connStatus) {
|
||||||
|
connStatus.className = 'ml-2 w-2 h-2 bg-red-500 rounded-full inline-block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial update
|
||||||
|
updateDiagnosticsData();
|
||||||
|
|
||||||
|
// Set up refresh interval and register it
|
||||||
|
const interval = setInterval(updateDiagnosticsData, 10000);
|
||||||
|
window.SLMConnectionManager.registerInterval(interval);
|
||||||
|
|
||||||
|
console.log(`Diagnostics card for ${diagUnitId} initialized`);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -28,14 +28,28 @@
|
|||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Units</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Units</h2>
|
||||||
|
|
||||||
<!-- Search/Filter -->
|
<!-- Search/Filter -->
|
||||||
<div class="mb-4">
|
<div class="mb-4 space-y-2">
|
||||||
<input type="text"
|
<!-- Project Filter -->
|
||||||
|
<select id="project-filter"
|
||||||
|
name="project"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
hx-get="/api/slm-dashboard/units"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#slm-list"
|
||||||
|
hx-include="#search-input, #project-filter">
|
||||||
|
<option value="">All Projects</option>
|
||||||
|
<!-- Will be populated dynamically -->
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Search Input -->
|
||||||
|
<input id="search-input"
|
||||||
|
type="text"
|
||||||
placeholder="Search units..."
|
placeholder="Search units..."
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
hx-get="/api/slm-dashboard/units"
|
hx-get="/api/slm-dashboard/units"
|
||||||
hx-trigger="keyup changed delay:300ms"
|
hx-trigger="keyup changed delay:300ms"
|
||||||
hx-target="#slm-list"
|
hx-target="#slm-list"
|
||||||
hx-include="this"
|
hx-include="#search-input, #project-filter"
|
||||||
name="search">
|
name="search">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,9 +107,94 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Command Center Modal -->
|
||||||
|
<div id="command-center-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50">
|
||||||
|
<div class="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-7xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-between z-10">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Command Center</h2>
|
||||||
|
<button onclick="closeCommandCenter()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="command-center-content" class="p-6">
|
||||||
|
<!-- Command center will load here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Function to select a unit and load live view
|
// Global Connection Manager - ensures only one SLM connection at a time
|
||||||
|
window.SLMConnectionManager = {
|
||||||
|
activeIntervals: [],
|
||||||
|
activeWebSocket: null,
|
||||||
|
currentUnitId: null,
|
||||||
|
|
||||||
|
// Clear all existing connections
|
||||||
|
clearAll: function() {
|
||||||
|
console.log('SLMConnectionManager: Clearing all connections');
|
||||||
|
|
||||||
|
// Clear all intervals
|
||||||
|
this.activeIntervals.forEach(interval => {
|
||||||
|
clearInterval(interval);
|
||||||
|
});
|
||||||
|
this.activeIntervals = [];
|
||||||
|
|
||||||
|
// Close WebSocket if exists
|
||||||
|
if (this.activeWebSocket) {
|
||||||
|
this.activeWebSocket.close();
|
||||||
|
this.activeWebSocket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any global intervals that might exist
|
||||||
|
if (window.refreshInterval) {
|
||||||
|
clearInterval(window.refreshInterval);
|
||||||
|
window.refreshInterval = null;
|
||||||
|
}
|
||||||
|
if (window.timerInterval) {
|
||||||
|
clearInterval(window.timerInterval);
|
||||||
|
window.timerInterval = null;
|
||||||
|
}
|
||||||
|
if (window.diagRefreshInterval) {
|
||||||
|
clearInterval(window.diagRefreshInterval);
|
||||||
|
window.diagRefreshInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('SLMConnectionManager: All connections cleared');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Register a new interval
|
||||||
|
registerInterval: function(intervalId) {
|
||||||
|
this.activeIntervals.push(intervalId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Register WebSocket
|
||||||
|
registerWebSocket: function(ws) {
|
||||||
|
if (this.activeWebSocket) {
|
||||||
|
this.activeWebSocket.close();
|
||||||
|
}
|
||||||
|
this.activeWebSocket = ws;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set current unit
|
||||||
|
setCurrentUnit: function(unitId) {
|
||||||
|
if (this.currentUnitId !== unitId) {
|
||||||
|
this.clearAll();
|
||||||
|
this.currentUnitId = unitId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to select a unit and load DIAGNOSTICS CARD (not full command center)
|
||||||
function selectUnit(unitId) {
|
function selectUnit(unitId) {
|
||||||
|
console.log(`Selecting unit: ${unitId}`);
|
||||||
|
|
||||||
|
// Clear all existing connections
|
||||||
|
window.SLMConnectionManager.clearAll();
|
||||||
|
|
||||||
// Remove active state from all items
|
// Remove active state from all items
|
||||||
document.querySelectorAll('.slm-unit-item').forEach(item => {
|
document.querySelectorAll('.slm-unit-item').forEach(item => {
|
||||||
item.classList.remove('bg-seismo-orange', 'text-white');
|
item.classList.remove('bg-seismo-orange', 'text-white');
|
||||||
@@ -106,13 +205,53 @@ function selectUnit(unitId) {
|
|||||||
event.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700');
|
event.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700');
|
||||||
event.currentTarget.classList.add('bg-seismo-orange', 'text-white');
|
event.currentTarget.classList.add('bg-seismo-orange', 'text-white');
|
||||||
|
|
||||||
// Load live view for this unit
|
// Load DIAGNOSTICS CARD (not full live view)
|
||||||
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
htmx.ajax('GET', `/api/slm-dashboard/diagnostics/${unitId}`, {
|
||||||
target: '#live-view-panel',
|
target: '#live-view-panel',
|
||||||
swap: 'innerHTML'
|
swap: 'innerHTML'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open command center in modal
|
||||||
|
function openCommandCenter(unitId) {
|
||||||
|
console.log(`Opening command center for: ${unitId}`);
|
||||||
|
|
||||||
|
// Clear diagnostics refresh before opening modal
|
||||||
|
window.SLMConnectionManager.clearAll();
|
||||||
|
|
||||||
|
const modal = document.getElementById('command-center-modal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Load full command center
|
||||||
|
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
||||||
|
target: '#command-center-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close command center modal
|
||||||
|
function closeCommandCenter() {
|
||||||
|
console.log('Closing command center');
|
||||||
|
|
||||||
|
// Clear all command center connections
|
||||||
|
window.SLMConnectionManager.clearAll();
|
||||||
|
|
||||||
|
document.getElementById('command-center-modal').classList.add('hidden');
|
||||||
|
|
||||||
|
// Reload the diagnostics card for the currently selected unit
|
||||||
|
const activeUnit = document.querySelector('.slm-unit-item.bg-seismo-orange');
|
||||||
|
if (activeUnit) {
|
||||||
|
const unitIdMatch = activeUnit.getAttribute('onclick').match(/selectUnit\('(.+?)'\)/);
|
||||||
|
if (unitIdMatch) {
|
||||||
|
const unitId = unitIdMatch[1];
|
||||||
|
htmx.ajax('GET', `/api/slm-dashboard/diagnostics/${unitId}`, {
|
||||||
|
target: '#live-view-panel',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Configuration modal functions
|
// Configuration modal functions
|
||||||
function openConfigModal(unitId) {
|
function openConfigModal(unitId) {
|
||||||
const modal = document.getElementById('config-modal');
|
const modal = document.getElementById('config-modal');
|
||||||
@@ -129,121 +268,48 @@ function closeConfigModal() {
|
|||||||
document.getElementById('config-modal').classList.add('hidden');
|
document.getElementById('config-modal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal on escape key
|
// Close modals on escape key
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeConfigModal();
|
closeConfigModal();
|
||||||
|
closeCommandCenter();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close modal when clicking outside
|
// Close modals when clicking outside
|
||||||
document.getElementById('config-modal')?.addEventListener('click', function(e) {
|
document.getElementById('config-modal')?.addEventListener('click', function(e) {
|
||||||
if (e.target === this) {
|
if (e.target === this) {
|
||||||
closeConfigModal();
|
closeConfigModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize WebSocket for selected unit
|
document.getElementById('command-center-modal')?.addEventListener('click', function(e) {
|
||||||
let currentWebSocket = null;
|
if (e.target === this) {
|
||||||
|
closeCommandCenter();
|
||||||
function initLiveDataStream(unitId) {
|
|
||||||
// Close existing connection if any
|
|
||||||
if (currentWebSocket) {
|
|
||||||
currentWebSocket.close();
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// WebSocket URL for SLMM backend via proxy
|
// Load projects for filter dropdown on page load
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
fetch('/api/slm-dashboard/projects')
|
||||||
|
.then(response => response.json())
|
||||||
currentWebSocket = new WebSocket(wsUrl);
|
.then(data => {
|
||||||
|
const projectFilter = document.getElementById('project-filter');
|
||||||
currentWebSocket.onopen = function() {
|
if (projectFilter && data.projects) {
|
||||||
console.log('WebSocket connected');
|
data.projects.forEach(project => {
|
||||||
// Toggle button visibility
|
const option = document.createElement('option');
|
||||||
const startBtn = document.getElementById('start-stream-btn');
|
option.value = project;
|
||||||
const stopBtn = document.getElementById('stop-stream-btn');
|
option.textContent = project;
|
||||||
if (startBtn) startBtn.style.display = 'none';
|
projectFilter.appendChild(option);
|
||||||
if (stopBtn) stopBtn.style.display = 'flex';
|
});
|
||||||
};
|
|
||||||
|
|
||||||
currentWebSocket.onmessage = function(event) {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
updateLiveChart(data);
|
|
||||||
updateLiveMetrics(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
currentWebSocket.onerror = function(error) {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
};
|
|
||||||
|
|
||||||
currentWebSocket.onclose = function() {
|
|
||||||
console.log('WebSocket closed');
|
|
||||||
// Toggle button visibility
|
|
||||||
const startBtn = document.getElementById('start-stream-btn');
|
|
||||||
const stopBtn = document.getElementById('stop-stream-btn');
|
|
||||||
if (startBtn) startBtn.style.display = 'flex';
|
|
||||||
if (stopBtn) stopBtn.style.display = 'none';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopLiveDataStream() {
|
|
||||||
if (currentWebSocket) {
|
|
||||||
currentWebSocket.close();
|
|
||||||
currentWebSocket = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update live chart with new data point
|
|
||||||
let chartData = {
|
|
||||||
timestamps: [],
|
|
||||||
lp: [],
|
|
||||||
leq: []
|
|
||||||
};
|
|
||||||
|
|
||||||
function updateLiveChart(data) {
|
|
||||||
const now = new Date();
|
|
||||||
chartData.timestamps.push(now.toLocaleTimeString());
|
|
||||||
chartData.lp.push(parseFloat(data.lp || 0));
|
|
||||||
chartData.leq.push(parseFloat(data.leq || 0));
|
|
||||||
|
|
||||||
// Keep only last 60 data points (1 minute at 1 sample/sec)
|
|
||||||
if (chartData.timestamps.length > 60) {
|
|
||||||
chartData.timestamps.shift();
|
|
||||||
chartData.lp.shift();
|
|
||||||
chartData.leq.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update chart (using Chart.js if available)
|
|
||||||
if (window.liveChart) {
|
|
||||||
window.liveChart.data.labels = chartData.timestamps;
|
|
||||||
window.liveChart.data.datasets[0].data = chartData.lp;
|
|
||||||
window.liveChart.data.datasets[1].data = chartData.leq;
|
|
||||||
window.liveChart.update('none'); // Update without animation for smooth real-time
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLiveMetrics(data) {
|
|
||||||
// Update metric displays
|
|
||||||
if (document.getElementById('live-lp')) {
|
|
||||||
document.getElementById('live-lp').textContent = data.lp || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-leq')) {
|
|
||||||
document.getElementById('live-leq').textContent = data.leq || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-lmax')) {
|
|
||||||
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-lmin')) {
|
|
||||||
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Failed to load projects:', error));
|
||||||
|
});
|
||||||
|
|
||||||
// Cleanup on page unload
|
// Cleanup on page unload
|
||||||
window.addEventListener('beforeunload', function() {
|
window.addEventListener('beforeunload', function() {
|
||||||
if (currentWebSocket) {
|
window.SLMConnectionManager.clearAll();
|
||||||
currentWebSocket.close();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user