chore: modular monolith folder split (no behavior change)
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
13
app/api/dashboard.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
API Aggregation Layer - Dashboard endpoints
|
||||||
|
Composes data from multiple feature modules
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/dashboard", tags=["dashboard-aggregation"])
|
||||||
|
|
||||||
|
# TODO: Implement aggregation endpoints that combine data from
|
||||||
|
# app.seismo and app.slm modules
|
||||||
|
|
||||||
|
# For now, individual feature modules expose their own APIs directly
|
||||||
|
# Future: Add cross-feature aggregation here
|
||||||
13
app/api/roster.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
API Aggregation Layer - Roster endpoints
|
||||||
|
Aggregates roster data from all feature modules
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/roster-aggregation", tags=["roster-aggregation"])
|
||||||
|
|
||||||
|
# TODO: Implement unified roster endpoints that combine data from
|
||||||
|
# app.seismo and app.slm modules
|
||||||
|
|
||||||
|
# For now, individual feature modules expose their own roster APIs
|
||||||
|
# Future: Add cross-feature roster aggregation here
|
||||||
0
app/core/__init__.py
Normal file
20
app/core/config.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Core configuration for Terra-View application
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Application
|
||||||
|
APP_NAME = "Terra-View"
|
||||||
|
VERSION = "1.0.0"
|
||||||
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
|
# Ports
|
||||||
|
PORT = int(os.getenv("PORT", 8001))
|
||||||
|
|
||||||
|
# External Services
|
||||||
|
SLMM_API_URL = os.getenv("SLMM_API_URL", "http://localhost:8100")
|
||||||
|
|
||||||
|
# Database URLs (feature-specific)
|
||||||
|
SEISMO_DATABASE_URL = "sqlite:///./data/seismo.db"
|
||||||
|
SLM_DATABASE_URL = "sqlite:///./data/slm.db"
|
||||||
|
MODEM_DATABASE_URL = "sqlite:///./data/modem.db"
|
||||||
31
app/core/database.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure data directory exists
|
||||||
|
os.makedirs("data", exist_ok=True)
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Dependency for database sessions"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_session():
|
||||||
|
"""Get a database session directly (not as a dependency)"""
|
||||||
|
return SessionLocal()
|
||||||
211
app/main.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
Terra-View - Unified monitoring platform for device fleets
|
||||||
|
Modular monolith architecture with strict feature boundaries
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Import configuration
|
||||||
|
from app.core.config import APP_NAME, VERSION, ENVIRONMENT
|
||||||
|
|
||||||
|
# Import UI routes
|
||||||
|
from app.ui import routes as ui_routes
|
||||||
|
|
||||||
|
# Import feature module routers (seismo)
|
||||||
|
from app.seismo.routers import (
|
||||||
|
roster as seismo_roster,
|
||||||
|
units as seismo_units,
|
||||||
|
photos as seismo_photos,
|
||||||
|
roster_edit as seismo_roster_edit,
|
||||||
|
dashboard as seismo_dashboard,
|
||||||
|
dashboard_tabs as seismo_dashboard_tabs,
|
||||||
|
activity as seismo_activity,
|
||||||
|
seismo_dashboard as seismo_seismo_dashboard,
|
||||||
|
settings as seismo_settings,
|
||||||
|
)
|
||||||
|
from app.seismo import routes as seismo_legacy_routes
|
||||||
|
|
||||||
|
# Import feature module routers (SLM)
|
||||||
|
from app.slm.routers import (
|
||||||
|
nl43_proxy as slm_nl43_proxy,
|
||||||
|
dashboard as slm_dashboard,
|
||||||
|
ui as slm_ui,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import API aggregation layer (placeholder for now)
|
||||||
|
from app.api import dashboard as api_dashboard
|
||||||
|
from app.api import roster as api_roster
|
||||||
|
|
||||||
|
# Initialize database tables
|
||||||
|
from app.seismo.database import engine as seismo_engine, Base as SeismoBase
|
||||||
|
SeismoBase.metadata.create_all(bind=seismo_engine)
|
||||||
|
|
||||||
|
# Initialize FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title=APP_NAME,
|
||||||
|
description="Unified monitoring platform for seismograph, modem, and sound level meter fleets",
|
||||||
|
version=VERSION
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add validation error handler to log details
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
|
logger.error(f"Validation error on {request.url}: {exc.errors()}")
|
||||||
|
logger.error(f"Body: {await request.body()}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"detail": exc.errors()}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount static files
|
||||||
|
app.mount("/static", StaticFiles(directory="app/ui/static"), name="static")
|
||||||
|
|
||||||
|
# Middleware to add environment to request state
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_environment_to_context(request: Request, call_next):
|
||||||
|
"""Middleware to add environment variable to request state"""
|
||||||
|
request.state.environment = ENVIRONMENT
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# ===== INCLUDE ROUTERS =====
|
||||||
|
|
||||||
|
# UI Layer (HTML pages)
|
||||||
|
app.include_router(ui_routes.router)
|
||||||
|
|
||||||
|
# Seismograph Feature Module APIs
|
||||||
|
app.include_router(seismo_roster.router)
|
||||||
|
app.include_router(seismo_units.router)
|
||||||
|
app.include_router(seismo_photos.router)
|
||||||
|
app.include_router(seismo_roster_edit.router)
|
||||||
|
app.include_router(seismo_dashboard.router)
|
||||||
|
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_legacy_routes.router)
|
||||||
|
|
||||||
|
# SLM Feature Module APIs
|
||||||
|
app.include_router(slm_nl43_proxy.router)
|
||||||
|
app.include_router(slm_dashboard.router)
|
||||||
|
app.include_router(slm_ui.router)
|
||||||
|
|
||||||
|
# API Aggregation Layer (future cross-feature endpoints)
|
||||||
|
# app.include_router(api_dashboard.router) # TODO: Implement aggregation
|
||||||
|
# app.include_router(api_roster.router) # TODO: Implement aggregation
|
||||||
|
|
||||||
|
# ===== ADDITIONAL ROUTES FROM OLD MAIN.PY =====
|
||||||
|
# These will need to be migrated to appropriate modules
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from typing import List, Dict
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
|
from app.seismo.models import IgnoredUnit
|
||||||
|
|
||||||
|
# TODO: Move these to appropriate feature modules or UI layer
|
||||||
|
|
||||||
|
@app.post("/api/sync-edits")
|
||||||
|
async def sync_edits(request: dict, db: Session = Depends(get_db)):
|
||||||
|
"""Process offline edit queue and sync to database"""
|
||||||
|
# TODO: Move to seismo module
|
||||||
|
from app.seismo.models import RosterUnit
|
||||||
|
|
||||||
|
class EditItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
unitId: str
|
||||||
|
changes: Dict
|
||||||
|
timestamp: int
|
||||||
|
|
||||||
|
class SyncEditsRequest(BaseModel):
|
||||||
|
edits: List[EditItem]
|
||||||
|
|
||||||
|
sync_request = SyncEditsRequest(**request)
|
||||||
|
results = []
|
||||||
|
synced_ids = []
|
||||||
|
|
||||||
|
for edit in sync_request.edits:
|
||||||
|
try:
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=edit.unitId).first()
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "error",
|
||||||
|
"reason": f"Unit {edit.unitId} not found"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
for key, value in edit.changes.items():
|
||||||
|
if hasattr(unit, key):
|
||||||
|
if key in ['deployed', 'retired']:
|
||||||
|
setattr(unit, key, value in ['true', True, 'True', '1', 1])
|
||||||
|
else:
|
||||||
|
setattr(unit, key, value if value != '' else None)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "success"
|
||||||
|
})
|
||||||
|
synced_ids.append(edit.id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "error",
|
||||||
|
"reason": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
synced_count = len(synced_ids)
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"synced": synced_count,
|
||||||
|
"total": len(sync_request.edits),
|
||||||
|
"synced_ids": synced_ids,
|
||||||
|
"results": results
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {
|
||||||
|
"message": f"{APP_NAME} v{VERSION}",
|
||||||
|
"status": "running",
|
||||||
|
"version": VERSION,
|
||||||
|
"modules": ["seismo", "slm"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
from app.core.config import PORT
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||||
0
app/modem/__init__.py
Normal file
0
app/seismo/__init__.py
Normal file
36
app/seismo/database.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Seismograph feature module database connection
|
||||||
|
"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure data directory exists
|
||||||
|
os.makedirs("data", exist_ok=True)
|
||||||
|
|
||||||
|
# For now, we'll use the old database (seismo_fleet.db) until we migrate
|
||||||
|
# TODO: Migrate to seismo.db
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Dependency for database sessions"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_session():
|
||||||
|
"""Get a database session directly (not as a dependency)"""
|
||||||
|
return SessionLocal()
|
||||||
110
app/seismo/models.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
|
||||||
|
from datetime import datetime
|
||||||
|
from backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Emitter(Base):
|
||||||
|
__tablename__ = "emitters"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
unit_type = Column(String, nullable=False)
|
||||||
|
last_seen = Column(DateTime, default=datetime.utcnow)
|
||||||
|
last_file = Column(String, nullable=False)
|
||||||
|
status = Column(String, nullable=False)
|
||||||
|
notes = Column(String, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RosterUnit(Base):
|
||||||
|
"""
|
||||||
|
Roster table: represents our *intended assignment* of a unit.
|
||||||
|
This is editable from the GUI.
|
||||||
|
|
||||||
|
Supports multiple device types (seismograph, modem, sound_level_meter) with type-specific fields.
|
||||||
|
"""
|
||||||
|
__tablename__ = "roster"
|
||||||
|
|
||||||
|
# Core fields (all device types)
|
||||||
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
unit_type = Column(String, default="series3") # Backward compatibility
|
||||||
|
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "sound_level_meter"
|
||||||
|
deployed = Column(Boolean, default=True)
|
||||||
|
retired = Column(Boolean, default=False)
|
||||||
|
note = Column(String, nullable=True)
|
||||||
|
project_id = Column(String, nullable=True)
|
||||||
|
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
||||||
|
address = Column(String, nullable=True) # Human-readable address
|
||||||
|
coordinates = Column(String, nullable=True) # Lat,Lon format: "34.0522,-118.2437"
|
||||||
|
last_updated = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Seismograph-specific fields (nullable for modems and SLMs)
|
||||||
|
last_calibrated = Column(Date, nullable=True)
|
||||||
|
next_calibration_due = Column(Date, nullable=True)
|
||||||
|
|
||||||
|
# Modem assignment (shared by seismographs and SLMs)
|
||||||
|
deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit (device_type=modem)
|
||||||
|
|
||||||
|
# Modem-specific fields (nullable for seismographs and SLMs)
|
||||||
|
ip_address = Column(String, nullable=True)
|
||||||
|
phone_number = Column(String, nullable=True)
|
||||||
|
hardware_model = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
||||||
|
slm_host = Column(String, nullable=True) # Device IP or hostname
|
||||||
|
slm_tcp_port = Column(Integer, nullable=True) # TCP control port (default 2255)
|
||||||
|
slm_ftp_port = Column(Integer, nullable=True) # FTP data retrieval port (default 21)
|
||||||
|
slm_model = Column(String, nullable=True) # NL-43, NL-53, etc.
|
||||||
|
slm_serial_number = Column(String, nullable=True) # Device serial number
|
||||||
|
slm_frequency_weighting = Column(String, nullable=True) # A, C, Z
|
||||||
|
slm_time_weighting = Column(String, nullable=True) # F (Fast), S (Slow), I (Impulse)
|
||||||
|
slm_measurement_range = Column(String, nullable=True) # e.g., "30-130 dB"
|
||||||
|
slm_last_check = Column(DateTime, nullable=True) # Last communication check
|
||||||
|
|
||||||
|
|
||||||
|
class IgnoredUnit(Base):
|
||||||
|
"""
|
||||||
|
Ignored units: units that report but should be filtered out from unknown emitters.
|
||||||
|
Used to suppress noise from old projects.
|
||||||
|
"""
|
||||||
|
__tablename__ = "ignored_units"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
reason = Column(String, nullable=True)
|
||||||
|
ignored_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class UnitHistory(Base):
|
||||||
|
"""
|
||||||
|
Unit history: complete timeline of changes to each unit.
|
||||||
|
Tracks note changes, status changes, deployment/benched events, and more.
|
||||||
|
"""
|
||||||
|
__tablename__ = "unit_history"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
||||||
|
change_type = Column(String, nullable=False) # note_change, deployed_change, retired_change, etc.
|
||||||
|
field_name = Column(String, nullable=True) # Which field changed
|
||||||
|
old_value = Column(Text, nullable=True) # Previous value
|
||||||
|
new_value = Column(Text, nullable=True) # New value
|
||||||
|
changed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||||
|
source = Column(String, default="manual") # manual, csv_import, telemetry, offline_sync
|
||||||
|
notes = Column(Text, nullable=True) # Optional reason/context for the change
|
||||||
|
|
||||||
|
|
||||||
|
class UserPreferences(Base):
|
||||||
|
"""
|
||||||
|
User preferences: persistent storage for application settings.
|
||||||
|
Single-row table (id=1) to store global user preferences.
|
||||||
|
"""
|
||||||
|
__tablename__ = "user_preferences"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, default=1)
|
||||||
|
timezone = Column(String, default="America/New_York")
|
||||||
|
theme = Column(String, default="auto") # auto, light, dark
|
||||||
|
auto_refresh_interval = Column(Integer, default=10) # seconds
|
||||||
|
date_format = Column(String, default="MM/DD/YYYY")
|
||||||
|
table_rows_per_page = Column(Integer, default=25)
|
||||||
|
calibration_interval_days = Column(Integer, default=365)
|
||||||
|
calibration_warning_days = Column(Integer, default=30)
|
||||||
|
status_ok_threshold_hours = Column(Integer, default=12)
|
||||||
|
status_pending_threshold_hours = Column(Integer, default=24)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
0
app/seismo/routers/__init__.py
Normal file
146
app/seismo/routers/activity.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import desc
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["activity"])
|
||||||
|
|
||||||
|
PHOTOS_BASE_DIR = Path("data/photos")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-activity")
|
||||||
|
def get_recent_activity(limit: int = 20, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get recent activity feed combining unit history changes and photo uploads.
|
||||||
|
Returns a unified timeline of events sorted by timestamp (newest first).
|
||||||
|
"""
|
||||||
|
activities = []
|
||||||
|
|
||||||
|
# Get recent history entries
|
||||||
|
history_entries = db.query(UnitHistory)\
|
||||||
|
.order_by(desc(UnitHistory.changed_at))\
|
||||||
|
.limit(limit * 2)\
|
||||||
|
.all() # Get more than needed to mix with photos
|
||||||
|
|
||||||
|
for entry in history_entries:
|
||||||
|
activity = {
|
||||||
|
"type": "history",
|
||||||
|
"timestamp": entry.changed_at.isoformat(),
|
||||||
|
"timestamp_unix": entry.changed_at.timestamp(),
|
||||||
|
"unit_id": entry.unit_id,
|
||||||
|
"change_type": entry.change_type,
|
||||||
|
"field_name": entry.field_name,
|
||||||
|
"old_value": entry.old_value,
|
||||||
|
"new_value": entry.new_value,
|
||||||
|
"source": entry.source,
|
||||||
|
"notes": entry.notes
|
||||||
|
}
|
||||||
|
activities.append(activity)
|
||||||
|
|
||||||
|
# Get recent photos
|
||||||
|
if PHOTOS_BASE_DIR.exists():
|
||||||
|
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||||
|
photo_activities = []
|
||||||
|
|
||||||
|
for unit_dir in PHOTOS_BASE_DIR.iterdir():
|
||||||
|
if not unit_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
unit_id = unit_dir.name
|
||||||
|
|
||||||
|
for file_path in unit_dir.iterdir():
|
||||||
|
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
|
||||||
|
modified_time = file_path.stat().st_mtime
|
||||||
|
photo_activities.append({
|
||||||
|
"type": "photo",
|
||||||
|
"timestamp": datetime.fromtimestamp(modified_time).isoformat(),
|
||||||
|
"timestamp_unix": modified_time,
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"filename": file_path.name,
|
||||||
|
"photo_url": f"/api/unit/{unit_id}/photo/{file_path.name}"
|
||||||
|
})
|
||||||
|
|
||||||
|
activities.extend(photo_activities)
|
||||||
|
|
||||||
|
# Sort all activities by timestamp (newest first)
|
||||||
|
activities.sort(key=lambda x: x["timestamp_unix"], reverse=True)
|
||||||
|
|
||||||
|
# Limit to requested number
|
||||||
|
activities = activities[:limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"activities": activities,
|
||||||
|
"total": len(activities)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-callins")
|
||||||
|
def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get recent unit call-ins (units that have reported recently).
|
||||||
|
Returns units sorted by most recent last_seen timestamp.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hours: Look back this many hours (default: 6)
|
||||||
|
limit: Maximum number of results (default: None = all)
|
||||||
|
"""
|
||||||
|
# Calculate the time threshold
|
||||||
|
time_threshold = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
|
||||||
|
# Query emitters with recent activity, joined with roster info
|
||||||
|
recent_emitters = db.query(Emitter)\
|
||||||
|
.filter(Emitter.last_seen >= time_threshold)\
|
||||||
|
.order_by(desc(Emitter.last_seen))\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
# Get roster info for all units
|
||||||
|
roster_dict = {r.id: r for r in db.query(RosterUnit).all()}
|
||||||
|
|
||||||
|
call_ins = []
|
||||||
|
for emitter in recent_emitters:
|
||||||
|
roster_unit = roster_dict.get(emitter.id)
|
||||||
|
|
||||||
|
# Calculate time since last seen
|
||||||
|
last_seen_utc = emitter.last_seen.replace(tzinfo=timezone.utc) if emitter.last_seen.tzinfo is None else emitter.last_seen
|
||||||
|
time_diff = datetime.now(timezone.utc) - last_seen_utc
|
||||||
|
|
||||||
|
# Format time ago
|
||||||
|
if time_diff.total_seconds() < 60:
|
||||||
|
time_ago = "just now"
|
||||||
|
elif time_diff.total_seconds() < 3600:
|
||||||
|
minutes = int(time_diff.total_seconds() / 60)
|
||||||
|
time_ago = f"{minutes}m ago"
|
||||||
|
else:
|
||||||
|
hours_ago = time_diff.total_seconds() / 3600
|
||||||
|
if hours_ago < 24:
|
||||||
|
time_ago = f"{int(hours_ago)}h {int((hours_ago % 1) * 60)}m ago"
|
||||||
|
else:
|
||||||
|
days = int(hours_ago / 24)
|
||||||
|
time_ago = f"{days}d ago"
|
||||||
|
|
||||||
|
call_in = {
|
||||||
|
"unit_id": emitter.id,
|
||||||
|
"last_seen": emitter.last_seen.isoformat(),
|
||||||
|
"time_ago": time_ago,
|
||||||
|
"status": emitter.status,
|
||||||
|
"device_type": roster_unit.device_type if roster_unit else "seismograph",
|
||||||
|
"deployed": roster_unit.deployed if roster_unit else False,
|
||||||
|
"note": roster_unit.note if roster_unit and roster_unit.note else "",
|
||||||
|
"location": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "")
|
||||||
|
}
|
||||||
|
call_ins.append(call_in)
|
||||||
|
|
||||||
|
# Apply limit if specified
|
||||||
|
if limit:
|
||||||
|
call_ins = call_ins[:limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"call_ins": call_ins,
|
||||||
|
"total": len(call_ins),
|
||||||
|
"hours": hours,
|
||||||
|
"time_threshold": time_threshold.isoformat()
|
||||||
|
}
|
||||||
25
app/seismo/routers/dashboard.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from fastapi import APIRouter, Request, Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/active")
|
||||||
|
def dashboard_active(request: Request):
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/active_table.html",
|
||||||
|
{"request": request, "units": snapshot["active"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/benched")
|
||||||
|
def dashboard_benched(request: Request):
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/benched_table.html",
|
||||||
|
{"request": request, "units": snapshot["benched"]}
|
||||||
|
)
|
||||||
34
app/seismo/routers/dashboard_tabs.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# backend/routers/dashboard_tabs.py
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["dashboard-tabs"])
|
||||||
|
|
||||||
|
@router.get("/active")
|
||||||
|
def get_active_units(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Return only ACTIVE (deployed) units for dashboard table swap.
|
||||||
|
"""
|
||||||
|
snap = emit_status_snapshot()
|
||||||
|
units = {
|
||||||
|
uid: u
|
||||||
|
for uid, u in snap["units"].items()
|
||||||
|
if u["deployed"] is True
|
||||||
|
}
|
||||||
|
return {"units": units}
|
||||||
|
|
||||||
|
@router.get("/benched")
|
||||||
|
def get_benched_units(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Return only BENCHED (not deployed) units for dashboard table swap.
|
||||||
|
"""
|
||||||
|
snap = emit_status_snapshot()
|
||||||
|
units = {
|
||||||
|
uid: u
|
||||||
|
for uid, u in snap["units"].items()
|
||||||
|
if u["deployed"] is False
|
||||||
|
}
|
||||||
|
return {"units": units}
|
||||||
242
app/seismo/routers/photos.py
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from PIL import Image
|
||||||
|
from PIL.ExifTags import TAGS, GPSTAGS
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["photos"])
|
||||||
|
|
||||||
|
PHOTOS_BASE_DIR = Path("data/photos")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_exif_data(image_path: Path) -> dict:
|
||||||
|
"""
|
||||||
|
Extract EXIF metadata from an image file.
|
||||||
|
Returns dict with timestamp, GPS coordinates, and other metadata.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
image = Image.open(image_path)
|
||||||
|
exif_data = image._getexif()
|
||||||
|
|
||||||
|
if not exif_data:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
# Extract standard EXIF tags
|
||||||
|
for tag_id, value in exif_data.items():
|
||||||
|
tag = TAGS.get(tag_id, tag_id)
|
||||||
|
|
||||||
|
# Extract datetime
|
||||||
|
if tag == "DateTime" or tag == "DateTimeOriginal":
|
||||||
|
try:
|
||||||
|
metadata["timestamp"] = datetime.strptime(str(value), "%Y:%m:%d %H:%M:%S")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Extract GPS data
|
||||||
|
if tag == "GPSInfo":
|
||||||
|
gps_data = {}
|
||||||
|
for gps_tag_id in value:
|
||||||
|
gps_tag = GPSTAGS.get(gps_tag_id, gps_tag_id)
|
||||||
|
gps_data[gps_tag] = value[gps_tag_id]
|
||||||
|
|
||||||
|
# Convert GPS data to decimal degrees
|
||||||
|
lat = gps_data.get("GPSLatitude")
|
||||||
|
lat_ref = gps_data.get("GPSLatitudeRef")
|
||||||
|
lon = gps_data.get("GPSLongitude")
|
||||||
|
lon_ref = gps_data.get("GPSLongitudeRef")
|
||||||
|
|
||||||
|
if lat and lon and lat_ref and lon_ref:
|
||||||
|
# Convert to decimal degrees
|
||||||
|
lat_decimal = convert_to_degrees(lat)
|
||||||
|
if lat_ref == "S":
|
||||||
|
lat_decimal = -lat_decimal
|
||||||
|
|
||||||
|
lon_decimal = convert_to_degrees(lon)
|
||||||
|
if lon_ref == "W":
|
||||||
|
lon_decimal = -lon_decimal
|
||||||
|
|
||||||
|
metadata["latitude"] = lat_decimal
|
||||||
|
metadata["longitude"] = lon_decimal
|
||||||
|
metadata["coordinates"] = f"{lat_decimal},{lon_decimal}"
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error extracting EXIF data: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_degrees(value):
|
||||||
|
"""
|
||||||
|
Convert GPS coordinates from degrees/minutes/seconds to decimal degrees.
|
||||||
|
"""
|
||||||
|
d, m, s = value
|
||||||
|
return float(d) + (float(m) / 60.0) + (float(s) / 3600.0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unit/{unit_id}/upload-photo")
|
||||||
|
async def upload_photo(
|
||||||
|
unit_id: str,
|
||||||
|
photo: UploadFile = File(...),
|
||||||
|
auto_populate_coords: bool = True,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Upload a photo for a unit and extract EXIF metadata.
|
||||||
|
If GPS data exists and auto_populate_coords is True, update the unit's coordinates.
|
||||||
|
"""
|
||||||
|
# Validate file type
|
||||||
|
allowed_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||||
|
file_ext = Path(photo.filename).suffix.lower()
|
||||||
|
|
||||||
|
if file_ext not in allowed_extensions:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create photos directory for this unit
|
||||||
|
unit_photo_dir = PHOTOS_BASE_DIR / unit_id
|
||||||
|
unit_photo_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate filename with timestamp to avoid collisions
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"{timestamp}_{photo.filename}"
|
||||||
|
file_path = unit_photo_dir / filename
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
try:
|
||||||
|
with open(file_path, "wb") as buffer:
|
||||||
|
shutil.copyfileobj(photo.file, buffer)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to save photo: {str(e)}")
|
||||||
|
|
||||||
|
# Extract EXIF metadata
|
||||||
|
metadata = extract_exif_data(file_path)
|
||||||
|
|
||||||
|
# Update unit coordinates if GPS data exists and auto_populate_coords is True
|
||||||
|
coordinates_updated = False
|
||||||
|
if auto_populate_coords and "coordinates" in metadata:
|
||||||
|
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
|
||||||
|
if roster_unit:
|
||||||
|
roster_unit.coordinates = metadata["coordinates"]
|
||||||
|
roster_unit.last_updated = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
coordinates_updated = True
|
||||||
|
|
||||||
|
return JSONResponse(content={
|
||||||
|
"success": True,
|
||||||
|
"filename": filename,
|
||||||
|
"file_path": f"/api/unit/{unit_id}/photo/{filename}",
|
||||||
|
"metadata": {
|
||||||
|
"timestamp": metadata.get("timestamp").isoformat() if metadata.get("timestamp") else None,
|
||||||
|
"latitude": metadata.get("latitude"),
|
||||||
|
"longitude": metadata.get("longitude"),
|
||||||
|
"coordinates": metadata.get("coordinates")
|
||||||
|
},
|
||||||
|
"coordinates_updated": coordinates_updated
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}/photos")
|
||||||
|
def get_unit_photos(unit_id: str):
|
||||||
|
"""
|
||||||
|
Reads /data/photos/<unit_id>/ and returns list of image filenames.
|
||||||
|
Primary photo = most recent file.
|
||||||
|
"""
|
||||||
|
unit_photo_dir = PHOTOS_BASE_DIR / unit_id
|
||||||
|
|
||||||
|
if not unit_photo_dir.exists():
|
||||||
|
# Return empty list if no photos directory exists
|
||||||
|
return {
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"photos": [],
|
||||||
|
"primary_photo": None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get all image files
|
||||||
|
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||||
|
photos = []
|
||||||
|
|
||||||
|
for file_path in unit_photo_dir.iterdir():
|
||||||
|
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
|
||||||
|
photos.append({
|
||||||
|
"filename": file_path.name,
|
||||||
|
"path": f"/api/unit/{unit_id}/photo/{file_path.name}",
|
||||||
|
"modified": file_path.stat().st_mtime
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by modification time (most recent first)
|
||||||
|
photos.sort(key=lambda x: x["modified"], reverse=True)
|
||||||
|
|
||||||
|
# Primary photo is the most recent
|
||||||
|
primary_photo = photos[0]["filename"] if photos else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"photos": [p["filename"] for p in photos],
|
||||||
|
"primary_photo": primary_photo,
|
||||||
|
"photo_urls": [p["path"] for p in photos]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-photos")
|
||||||
|
def get_recent_photos(limit: int = 12):
|
||||||
|
"""
|
||||||
|
Get the most recently uploaded photos across all units.
|
||||||
|
Returns photos sorted by modification time (newest first).
|
||||||
|
"""
|
||||||
|
if not PHOTOS_BASE_DIR.exists():
|
||||||
|
return {"photos": []}
|
||||||
|
|
||||||
|
all_photos = []
|
||||||
|
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||||
|
|
||||||
|
# Scan all unit directories
|
||||||
|
for unit_dir in PHOTOS_BASE_DIR.iterdir():
|
||||||
|
if not unit_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
unit_id = unit_dir.name
|
||||||
|
|
||||||
|
# Get all photos in this unit's directory
|
||||||
|
for file_path in unit_dir.iterdir():
|
||||||
|
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
|
||||||
|
all_photos.append({
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"filename": file_path.name,
|
||||||
|
"path": f"/api/unit/{unit_id}/photo/{file_path.name}",
|
||||||
|
"modified": file_path.stat().st_mtime,
|
||||||
|
"modified_iso": datetime.fromtimestamp(file_path.stat().st_mtime).isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by modification time (most recent first) and limit
|
||||||
|
all_photos.sort(key=lambda x: x["modified"], reverse=True)
|
||||||
|
recent_photos = all_photos[:limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"photos": recent_photos,
|
||||||
|
"total": len(all_photos)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}/photo/{filename}")
|
||||||
|
def get_photo(unit_id: str, filename: str):
|
||||||
|
"""
|
||||||
|
Serves a specific photo file.
|
||||||
|
"""
|
||||||
|
file_path = PHOTOS_BASE_DIR / unit_id / filename
|
||||||
|
|
||||||
|
if not file_path.exists() or not file_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Photo not found")
|
||||||
|
|
||||||
|
return FileResponse(file_path)
|
||||||
46
app/seismo/routers/roster.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any
|
||||||
|
import random
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["roster"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status-snapshot")
|
||||||
|
def get_status_snapshot(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Calls emit_status_snapshot() to get current fleet status.
|
||||||
|
This will be replaced with real Series3 emitter logic later.
|
||||||
|
"""
|
||||||
|
return emit_status_snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roster")
|
||||||
|
def get_roster(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns list of units with their metadata and status.
|
||||||
|
Uses mock data for now.
|
||||||
|
"""
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
units_list = []
|
||||||
|
|
||||||
|
for unit_id, unit_data in snapshot["units"].items():
|
||||||
|
units_list.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": unit_data["status"],
|
||||||
|
"age": unit_data["age"],
|
||||||
|
"last_seen": unit_data["last"],
|
||||||
|
"deployed": unit_data["deployed"],
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"last_file": unit_data.get("fname", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by status priority (Missing > Pending > OK) then by ID
|
||||||
|
status_priority = {"Missing": 0, "Pending": 1, "OK": 2}
|
||||||
|
units_list.sort(key=lambda x: (status_priority.get(x["status"], 3), x["id"]))
|
||||||
|
|
||||||
|
return {"units": units_list}
|
||||||
720
app/seismo/routers/roster_edit.py
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, date
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
import os
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# SLMM backend URL for syncing device configs to cache
|
||||||
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
|
||||||
|
|
||||||
|
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
||||||
|
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
|
||||||
|
"""Helper function to record a change in unit history"""
|
||||||
|
history_entry = UnitHistory(
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type=change_type,
|
||||||
|
field_name=field_name,
|
||||||
|
old_value=old_value,
|
||||||
|
new_value=new_value,
|
||||||
|
changed_at=datetime.utcnow(),
|
||||||
|
source=source,
|
||||||
|
notes=notes
|
||||||
|
)
|
||||||
|
db.add(history_entry)
|
||||||
|
# Note: caller is responsible for db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_roster_unit(db: Session, unit_id: str):
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
unit = RosterUnit(id=unit_id)
|
||||||
|
db.add(unit)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(unit)
|
||||||
|
return unit
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_slm_to_slmm_cache(
|
||||||
|
unit_id: str,
|
||||||
|
host: str = None,
|
||||||
|
tcp_port: int = None,
|
||||||
|
ftp_port: int = None,
|
||||||
|
ftp_username: str = None,
|
||||||
|
ftp_password: str = None,
|
||||||
|
deployed_with_modem_id: str = None,
|
||||||
|
db: Session = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Sync SLM device configuration to SLMM backend cache.
|
||||||
|
|
||||||
|
Terra-View is the source of truth for device configs. This function updates
|
||||||
|
SLMM's config cache (NL43Config table) so SLMM can look up device connection
|
||||||
|
info by unit_id without Terra-View passing host:port with every request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unique identifier for the SLM device
|
||||||
|
host: Direct IP address/hostname OR will be resolved from modem
|
||||||
|
tcp_port: TCP control port (default: 2255)
|
||||||
|
ftp_port: FTP port (default: 21)
|
||||||
|
ftp_username: FTP username (optional)
|
||||||
|
ftp_password: FTP password (optional)
|
||||||
|
deployed_with_modem_id: If set, resolve modem IP as host
|
||||||
|
db: Database session for modem lookup
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"success": bool, "message": str}
|
||||||
|
"""
|
||||||
|
# Resolve host from modem if assigned
|
||||||
|
if deployed_with_modem_id and db:
|
||||||
|
modem = db.query(RosterUnit).filter_by(
|
||||||
|
id=deployed_with_modem_id,
|
||||||
|
device_type="modem"
|
||||||
|
).first()
|
||||||
|
if modem and modem.ip_address:
|
||||||
|
host = modem.ip_address
|
||||||
|
logger.info(f"Resolved host from modem {deployed_with_modem_id}: {host}")
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not host:
|
||||||
|
logger.warning(f"Cannot sync SLM {unit_id} to SLMM: no host/IP address provided")
|
||||||
|
return {"success": False, "message": "No host IP address available"}
|
||||||
|
|
||||||
|
# Set defaults
|
||||||
|
tcp_port = tcp_port or 2255
|
||||||
|
ftp_port = ftp_port or 21
|
||||||
|
|
||||||
|
# Build SLMM cache payload
|
||||||
|
config_payload = {
|
||||||
|
"host": host,
|
||||||
|
"tcp_port": tcp_port,
|
||||||
|
"tcp_enabled": True,
|
||||||
|
"ftp_enabled": bool(ftp_username and ftp_password),
|
||||||
|
"web_enabled": False
|
||||||
|
}
|
||||||
|
|
||||||
|
if ftp_username and ftp_password:
|
||||||
|
config_payload["ftp_username"] = ftp_username
|
||||||
|
config_payload["ftp_password"] = ftp_password
|
||||||
|
|
||||||
|
# Call SLMM cache update API
|
||||||
|
slmm_url = f"{SLMM_BASE_URL}/api/nl43/{unit_id}/config"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.put(slmm_url, json=config_payload)
|
||||||
|
|
||||||
|
if response.status_code in [200, 201]:
|
||||||
|
logger.info(f"Successfully synced SLM {unit_id} to SLMM cache")
|
||||||
|
return {"success": True, "message": "Device config cached in SLMM"}
|
||||||
|
else:
|
||||||
|
logger.error(f"SLMM cache sync failed for {unit_id}: HTTP {response.status_code}")
|
||||||
|
return {"success": False, "message": f"SLMM returned status {response.status_code}"}
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
logger.error(f"Cannot connect to SLMM service at {SLMM_BASE_URL}")
|
||||||
|
return {"success": False, "message": "SLMM service unavailable"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error syncing SLM {unit_id} to SLMM: {e}")
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/add")
|
||||||
|
async def add_roster_unit(
|
||||||
|
id: str = Form(...),
|
||||||
|
device_type: str = Form("seismograph"),
|
||||||
|
unit_type: str = Form("series3"),
|
||||||
|
deployed: str = Form(None),
|
||||||
|
retired: str = Form(None),
|
||||||
|
note: str = Form(""),
|
||||||
|
project_id: str = Form(None),
|
||||||
|
location: str = Form(None),
|
||||||
|
address: str = Form(None),
|
||||||
|
coordinates: str = Form(None),
|
||||||
|
# Seismograph-specific fields
|
||||||
|
last_calibrated: str = Form(None),
|
||||||
|
next_calibration_due: str = Form(None),
|
||||||
|
deployed_with_modem_id: str = Form(None),
|
||||||
|
# Modem-specific fields
|
||||||
|
ip_address: str = Form(None),
|
||||||
|
phone_number: str = Form(None),
|
||||||
|
hardware_model: str = Form(None),
|
||||||
|
# Sound Level Meter-specific fields
|
||||||
|
slm_host: str = Form(None),
|
||||||
|
slm_tcp_port: str = Form(None),
|
||||||
|
slm_ftp_port: str = Form(None),
|
||||||
|
slm_model: str = Form(None),
|
||||||
|
slm_serial_number: str = Form(None),
|
||||||
|
slm_frequency_weighting: str = Form(None),
|
||||||
|
slm_time_weighting: str = Form(None),
|
||||||
|
slm_measurement_range: str = Form(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
logger.info(f"Adding unit: id={id}, device_type={device_type}, deployed={deployed}, retired={retired}")
|
||||||
|
|
||||||
|
# Convert boolean strings to actual booleans
|
||||||
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||||
|
|
||||||
|
# Convert port strings to integers
|
||||||
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||||
|
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
||||||
|
|
||||||
|
if db.query(RosterUnit).filter(RosterUnit.id == id).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Unit already exists")
|
||||||
|
|
||||||
|
# Parse date fields if provided
|
||||||
|
last_cal_date = None
|
||||||
|
if last_calibrated:
|
||||||
|
try:
|
||||||
|
last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
next_cal_date = None
|
||||||
|
if next_calibration_due:
|
||||||
|
try:
|
||||||
|
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
unit = RosterUnit(
|
||||||
|
id=id,
|
||||||
|
device_type=device_type,
|
||||||
|
unit_type=unit_type,
|
||||||
|
deployed=deployed_bool,
|
||||||
|
retired=retired_bool,
|
||||||
|
note=note,
|
||||||
|
project_id=project_id,
|
||||||
|
location=location,
|
||||||
|
address=address,
|
||||||
|
coordinates=coordinates,
|
||||||
|
last_updated=datetime.utcnow(),
|
||||||
|
# Seismograph-specific fields
|
||||||
|
last_calibrated=last_cal_date,
|
||||||
|
next_calibration_due=next_cal_date,
|
||||||
|
deployed_with_modem_id=deployed_with_modem_id if deployed_with_modem_id else None,
|
||||||
|
# Modem-specific fields
|
||||||
|
ip_address=ip_address if ip_address else None,
|
||||||
|
phone_number=phone_number if phone_number else None,
|
||||||
|
hardware_model=hardware_model if hardware_model else None,
|
||||||
|
# Sound Level Meter-specific fields
|
||||||
|
slm_host=slm_host if slm_host else None,
|
||||||
|
slm_tcp_port=slm_tcp_port_int,
|
||||||
|
slm_ftp_port=slm_ftp_port_int,
|
||||||
|
slm_model=slm_model if slm_model else None,
|
||||||
|
slm_serial_number=slm_serial_number if slm_serial_number else None,
|
||||||
|
slm_frequency_weighting=slm_frequency_weighting if slm_frequency_weighting else None,
|
||||||
|
slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
|
||||||
|
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
||||||
|
)
|
||||||
|
db.add(unit)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# If sound level meter, sync config to SLMM cache
|
||||||
|
if device_type == "sound_level_meter":
|
||||||
|
logger.info(f"Syncing SLM {id} config to SLMM cache...")
|
||||||
|
result = await sync_slm_to_slmm_cache(
|
||||||
|
unit_id=id,
|
||||||
|
host=slm_host,
|
||||||
|
tcp_port=slm_tcp_port_int,
|
||||||
|
ftp_port=slm_ftp_port_int,
|
||||||
|
deployed_with_modem_id=deployed_with_modem_id,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning(f"SLMM cache sync warning for {id}: {result['message']}")
|
||||||
|
# Don't fail the operation - device is still added to Terra-View roster
|
||||||
|
# User can manually sync later or SLMM will be synced on next config update
|
||||||
|
|
||||||
|
return {"message": "Unit added", "id": id, "device_type": device_type}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/modems")
|
||||||
|
def get_modems_list(db: Session = Depends(get_db)):
|
||||||
|
"""Get list of all modem units for dropdown selection"""
|
||||||
|
modems = db.query(RosterUnit).filter_by(device_type="modem", retired=False).order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": modem.id,
|
||||||
|
"ip_address": modem.ip_address,
|
||||||
|
"phone_number": modem.phone_number,
|
||||||
|
"hardware_model": modem.hardware_model,
|
||||||
|
"deployed": modem.deployed
|
||||||
|
}
|
||||||
|
for modem in modems
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{unit_id}")
|
||||||
|
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Get a single roster unit by ID"""
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": unit.id,
|
||||||
|
"device_type": unit.device_type or "seismograph",
|
||||||
|
"unit_type": unit.unit_type,
|
||||||
|
"deployed": unit.deployed,
|
||||||
|
"retired": unit.retired,
|
||||||
|
"note": unit.note or "",
|
||||||
|
"project_id": unit.project_id or "",
|
||||||
|
"location": unit.location or "",
|
||||||
|
"address": unit.address or "",
|
||||||
|
"coordinates": unit.coordinates or "",
|
||||||
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
|
||||||
|
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
|
||||||
|
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||||
|
"ip_address": unit.ip_address or "",
|
||||||
|
"phone_number": unit.phone_number or "",
|
||||||
|
"hardware_model": unit.hardware_model or "",
|
||||||
|
"slm_host": unit.slm_host or "",
|
||||||
|
"slm_tcp_port": unit.slm_tcp_port or "",
|
||||||
|
"slm_ftp_port": unit.slm_ftp_port or "",
|
||||||
|
"slm_model": unit.slm_model or "",
|
||||||
|
"slm_serial_number": unit.slm_serial_number or "",
|
||||||
|
"slm_frequency_weighting": unit.slm_frequency_weighting or "",
|
||||||
|
"slm_time_weighting": unit.slm_time_weighting or "",
|
||||||
|
"slm_measurement_range": unit.slm_measurement_range or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/edit/{unit_id}")
|
||||||
|
def edit_roster_unit(
|
||||||
|
unit_id: str,
|
||||||
|
device_type: str = Form("seismograph"),
|
||||||
|
unit_type: str = Form("series3"),
|
||||||
|
deployed: str = Form(None),
|
||||||
|
retired: str = Form(None),
|
||||||
|
note: str = Form(""),
|
||||||
|
project_id: str = Form(None),
|
||||||
|
location: str = Form(None),
|
||||||
|
address: str = Form(None),
|
||||||
|
coordinates: str = Form(None),
|
||||||
|
# Seismograph-specific fields
|
||||||
|
last_calibrated: str = Form(None),
|
||||||
|
next_calibration_due: str = Form(None),
|
||||||
|
deployed_with_modem_id: str = Form(None),
|
||||||
|
# Modem-specific fields
|
||||||
|
ip_address: str = Form(None),
|
||||||
|
phone_number: str = Form(None),
|
||||||
|
hardware_model: str = Form(None),
|
||||||
|
# Sound Level Meter-specific fields
|
||||||
|
slm_host: str = Form(None),
|
||||||
|
slm_tcp_port: str = Form(None),
|
||||||
|
slm_ftp_port: str = Form(None),
|
||||||
|
slm_model: str = Form(None),
|
||||||
|
slm_serial_number: str = Form(None),
|
||||||
|
slm_frequency_weighting: str = Form(None),
|
||||||
|
slm_time_weighting: str = Form(None),
|
||||||
|
slm_measurement_range: str = Form(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
# Convert boolean strings to actual booleans
|
||||||
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||||
|
|
||||||
|
# Convert port strings to integers
|
||||||
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||||
|
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
||||||
|
|
||||||
|
# Parse date fields if provided
|
||||||
|
last_cal_date = None
|
||||||
|
if last_calibrated:
|
||||||
|
try:
|
||||||
|
last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
next_cal_date = None
|
||||||
|
if next_calibration_due:
|
||||||
|
try:
|
||||||
|
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
# Track changes for history
|
||||||
|
old_note = unit.note
|
||||||
|
old_deployed = unit.deployed
|
||||||
|
old_retired = unit.retired
|
||||||
|
|
||||||
|
# Update all fields
|
||||||
|
unit.device_type = device_type
|
||||||
|
unit.unit_type = unit_type
|
||||||
|
unit.deployed = deployed_bool
|
||||||
|
unit.retired = retired_bool
|
||||||
|
unit.note = note
|
||||||
|
unit.project_id = project_id
|
||||||
|
unit.location = location
|
||||||
|
unit.address = address
|
||||||
|
unit.coordinates = coordinates
|
||||||
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Seismograph-specific fields
|
||||||
|
unit.last_calibrated = last_cal_date
|
||||||
|
unit.next_calibration_due = next_cal_date
|
||||||
|
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
||||||
|
|
||||||
|
# Modem-specific fields
|
||||||
|
unit.ip_address = ip_address if ip_address else None
|
||||||
|
unit.phone_number = phone_number if phone_number else None
|
||||||
|
unit.hardware_model = hardware_model if hardware_model else None
|
||||||
|
|
||||||
|
# Sound Level Meter-specific fields
|
||||||
|
unit.slm_host = slm_host if slm_host else None
|
||||||
|
unit.slm_tcp_port = slm_tcp_port_int
|
||||||
|
unit.slm_ftp_port = slm_ftp_port_int
|
||||||
|
unit.slm_model = slm_model if slm_model else None
|
||||||
|
unit.slm_serial_number = slm_serial_number if slm_serial_number else None
|
||||||
|
unit.slm_frequency_weighting = slm_frequency_weighting if slm_frequency_weighting else None
|
||||||
|
unit.slm_time_weighting = slm_time_weighting if slm_time_weighting else None
|
||||||
|
unit.slm_measurement_range = slm_measurement_range if slm_measurement_range else None
|
||||||
|
|
||||||
|
# Record history entries for changed fields
|
||||||
|
if old_note != note:
|
||||||
|
record_history(db, unit_id, "note_change", "note", old_note, note, "manual")
|
||||||
|
|
||||||
|
if old_deployed != deployed:
|
||||||
|
status_text = "deployed" if deployed else "benched"
|
||||||
|
old_status_text = "deployed" if old_deployed else "benched"
|
||||||
|
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
|
||||||
|
|
||||||
|
if old_retired != retired:
|
||||||
|
status_text = "retired" if retired else "active"
|
||||||
|
old_status_text = "retired" if old_retired else "active"
|
||||||
|
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-deployed/{unit_id}")
|
||||||
|
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
||||||
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
|
old_deployed = unit.deployed
|
||||||
|
unit.deployed = deployed
|
||||||
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Record history entry for deployed status change
|
||||||
|
if old_deployed != deployed:
|
||||||
|
status_text = "deployed" if deployed else "benched"
|
||||||
|
old_status_text = "deployed" if old_deployed else "benched"
|
||||||
|
record_history(
|
||||||
|
db=db,
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="deployed_change",
|
||||||
|
field_name="deployed",
|
||||||
|
old_value=old_status_text,
|
||||||
|
new_value=status_text,
|
||||||
|
source="manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Updated", "id": unit_id, "deployed": deployed}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-retired/{unit_id}")
|
||||||
|
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
||||||
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
|
old_retired = unit.retired
|
||||||
|
unit.retired = retired
|
||||||
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Record history entry for retired status change
|
||||||
|
if old_retired != retired:
|
||||||
|
status_text = "retired" if retired else "active"
|
||||||
|
old_status_text = "retired" if old_retired else "active"
|
||||||
|
record_history(
|
||||||
|
db=db,
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="retired_change",
|
||||||
|
field_name="retired",
|
||||||
|
old_value=old_status_text,
|
||||||
|
new_value=status_text,
|
||||||
|
source="manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Updated", "id": unit_id, "retired": retired}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{unit_id}")
|
||||||
|
def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Permanently delete a unit from the database.
|
||||||
|
Checks roster, emitters, and ignored_units tables and deletes from any table where the unit exists.
|
||||||
|
"""
|
||||||
|
deleted = False
|
||||||
|
|
||||||
|
# Try to delete from roster table
|
||||||
|
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if roster_unit:
|
||||||
|
db.delete(roster_unit)
|
||||||
|
deleted = True
|
||||||
|
|
||||||
|
# Try to delete from emitters table
|
||||||
|
emitter = db.query(Emitter).filter(Emitter.id == unit_id).first()
|
||||||
|
if emitter:
|
||||||
|
db.delete(emitter)
|
||||||
|
deleted = True
|
||||||
|
|
||||||
|
# Try to delete from ignored_units table
|
||||||
|
ignored_unit = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first()
|
||||||
|
if ignored_unit:
|
||||||
|
db.delete(ignored_unit)
|
||||||
|
deleted = True
|
||||||
|
|
||||||
|
# If not found in any table, return error
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Unit deleted", "id": unit_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-note/{unit_id}")
|
||||||
|
def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
|
||||||
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
|
old_note = unit.note
|
||||||
|
unit.note = note
|
||||||
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Record history entry for note change
|
||||||
|
if old_note != note:
|
||||||
|
record_history(
|
||||||
|
db=db,
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="note_change",
|
||||||
|
field_name="note",
|
||||||
|
old_value=old_note,
|
||||||
|
new_value=note,
|
||||||
|
source="manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Updated", "id": unit_id, "note": note}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-csv")
|
||||||
|
async def import_csv(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
update_existing: bool = Form(True),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Import roster units from CSV file.
|
||||||
|
|
||||||
|
Expected CSV columns (unit_id is required, others are optional):
|
||||||
|
- unit_id: Unique identifier for the unit
|
||||||
|
- unit_type: Type of unit (default: "series3")
|
||||||
|
- deployed: Boolean for deployment status (default: False)
|
||||||
|
- retired: Boolean for retirement status (default: False)
|
||||||
|
- note: Notes about the unit
|
||||||
|
- project_id: Project identifier
|
||||||
|
- location: Location description
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: CSV file upload
|
||||||
|
update_existing: If True, update existing units; if False, skip them
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not file.filename.endswith('.csv'):
|
||||||
|
raise HTTPException(status_code=400, detail="File must be a CSV")
|
||||||
|
|
||||||
|
# Read file content
|
||||||
|
contents = await file.read()
|
||||||
|
csv_text = contents.decode('utf-8')
|
||||||
|
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"added": [],
|
||||||
|
"updated": [],
|
||||||
|
"skipped": [],
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 to account for header
|
||||||
|
try:
|
||||||
|
# Validate required field
|
||||||
|
unit_id = row.get('unit_id', '').strip()
|
||||||
|
if not unit_id:
|
||||||
|
results["errors"].append({
|
||||||
|
"row": row_num,
|
||||||
|
"error": "Missing required field: unit_id"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if unit exists
|
||||||
|
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
|
||||||
|
if existing_unit:
|
||||||
|
if not update_existing:
|
||||||
|
results["skipped"].append(unit_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update existing unit
|
||||||
|
existing_unit.unit_type = row.get('unit_type', existing_unit.unit_type or 'series3')
|
||||||
|
existing_unit.deployed = row.get('deployed', '').lower() in ('true', '1', 'yes') if row.get('deployed') else existing_unit.deployed
|
||||||
|
existing_unit.retired = row.get('retired', '').lower() in ('true', '1', 'yes') if row.get('retired') else existing_unit.retired
|
||||||
|
existing_unit.note = row.get('note', existing_unit.note or '')
|
||||||
|
existing_unit.project_id = row.get('project_id', existing_unit.project_id)
|
||||||
|
existing_unit.location = row.get('location', existing_unit.location)
|
||||||
|
existing_unit.address = row.get('address', existing_unit.address)
|
||||||
|
existing_unit.coordinates = row.get('coordinates', existing_unit.coordinates)
|
||||||
|
existing_unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
results["updated"].append(unit_id)
|
||||||
|
else:
|
||||||
|
# Create new unit
|
||||||
|
new_unit = RosterUnit(
|
||||||
|
id=unit_id,
|
||||||
|
unit_type=row.get('unit_type', 'series3'),
|
||||||
|
deployed=row.get('deployed', '').lower() in ('true', '1', 'yes'),
|
||||||
|
retired=row.get('retired', '').lower() in ('true', '1', 'yes'),
|
||||||
|
note=row.get('note', ''),
|
||||||
|
project_id=row.get('project_id'),
|
||||||
|
location=row.get('location'),
|
||||||
|
address=row.get('address'),
|
||||||
|
coordinates=row.get('coordinates'),
|
||||||
|
last_updated=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(new_unit)
|
||||||
|
results["added"].append(unit_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
results["errors"].append({
|
||||||
|
"row": row_num,
|
||||||
|
"unit_id": row.get('unit_id', 'unknown'),
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Commit all changes
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "CSV import completed",
|
||||||
|
"summary": {
|
||||||
|
"added": len(results["added"]),
|
||||||
|
"updated": len(results["updated"]),
|
||||||
|
"skipped": len(results["skipped"]),
|
||||||
|
"errors": len(results["errors"])
|
||||||
|
},
|
||||||
|
"details": results
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ignore/{unit_id}")
|
||||||
|
def ignore_unit(unit_id: str, reason: str = Form(""), db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Add a unit to the ignore list to suppress it from unknown emitters.
|
||||||
|
"""
|
||||||
|
# Check if already ignored
|
||||||
|
if db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Unit already ignored")
|
||||||
|
|
||||||
|
ignored = IgnoredUnit(
|
||||||
|
id=unit_id,
|
||||||
|
reason=reason,
|
||||||
|
ignored_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(ignored)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Unit ignored", "id": unit_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/ignore/{unit_id}")
|
||||||
|
def unignore_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Remove a unit from the ignore list.
|
||||||
|
"""
|
||||||
|
ignored = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first()
|
||||||
|
if not ignored:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not in ignore list")
|
||||||
|
|
||||||
|
db.delete(ignored)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Unit unignored", "id": unit_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ignored")
|
||||||
|
def list_ignored_units(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get list of all ignored units.
|
||||||
|
"""
|
||||||
|
ignored_units = db.query(IgnoredUnit).all()
|
||||||
|
return {
|
||||||
|
"ignored": [
|
||||||
|
{
|
||||||
|
"id": unit.id,
|
||||||
|
"reason": unit.reason,
|
||||||
|
"ignored_at": unit.ignored_at.isoformat()
|
||||||
|
}
|
||||||
|
for unit in ignored_units
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/{unit_id}")
|
||||||
|
def get_unit_history(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get complete history timeline for a unit.
|
||||||
|
Returns all historical changes ordered by most recent first.
|
||||||
|
"""
|
||||||
|
history_entries = db.query(UnitHistory).filter(
|
||||||
|
UnitHistory.unit_id == unit_id
|
||||||
|
).order_by(UnitHistory.changed_at.desc()).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"id": entry.id,
|
||||||
|
"change_type": entry.change_type,
|
||||||
|
"field_name": entry.field_name,
|
||||||
|
"old_value": entry.old_value,
|
||||||
|
"new_value": entry.new_value,
|
||||||
|
"changed_at": entry.changed_at.isoformat(),
|
||||||
|
"source": entry.source,
|
||||||
|
"notes": entry.notes
|
||||||
|
}
|
||||||
|
for entry in history_entries
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/history/{history_id}")
|
||||||
|
def delete_history_entry(history_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Delete a specific history entry by ID.
|
||||||
|
Allows manual cleanup of old history entries.
|
||||||
|
"""
|
||||||
|
history_entry = db.query(UnitHistory).filter(UnitHistory.id == history_id).first()
|
||||||
|
if not history_entry:
|
||||||
|
raise HTTPException(status_code=404, detail="History entry not found")
|
||||||
|
|
||||||
|
db.delete(history_entry)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "History entry deleted", "id": history_id}
|
||||||
81
app/seismo/routers/seismo_dashboard.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
Seismograph Dashboard API Router
|
||||||
|
Provides endpoints for the seismograph-specific dashboard
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends, Query
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_class=HTMLResponse)
|
||||||
|
async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns HTML partial with seismograph statistics summary
|
||||||
|
"""
|
||||||
|
# Get all seismograph units
|
||||||
|
seismos = db.query(RosterUnit).filter_by(
|
||||||
|
device_type="seismograph",
|
||||||
|
retired=False
|
||||||
|
).all()
|
||||||
|
|
||||||
|
total = len(seismos)
|
||||||
|
deployed = sum(1 for s in seismos if s.deployed)
|
||||||
|
benched = sum(1 for s in seismos if not s.deployed)
|
||||||
|
|
||||||
|
# Count modems assigned to deployed seismographs
|
||||||
|
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
||||||
|
without_modem = deployed - with_modem
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/seismo_stats.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"total": total,
|
||||||
|
"deployed": deployed,
|
||||||
|
"benched": benched,
|
||||||
|
"with_modem": with_modem,
|
||||||
|
"without_modem": without_modem
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/units", response_class=HTMLResponse)
|
||||||
|
async def get_seismo_units(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
search: str = Query(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns HTML partial with filterable seismograph unit list
|
||||||
|
"""
|
||||||
|
query = db.query(RosterUnit).filter_by(
|
||||||
|
device_type="seismograph",
|
||||||
|
retired=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply search filter
|
||||||
|
if search:
|
||||||
|
search_lower = search.lower()
|
||||||
|
query = query.filter(
|
||||||
|
(RosterUnit.id.ilike(f"%{search}%")) |
|
||||||
|
(RosterUnit.note.ilike(f"%{search}%")) |
|
||||||
|
(RosterUnit.address.ilike(f"%{search}%"))
|
||||||
|
)
|
||||||
|
|
||||||
|
seismos = query.order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/seismo_unit_list.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"units": seismos,
|
||||||
|
"search": search or ""
|
||||||
|
}
|
||||||
|
)
|
||||||
479
app/seismo/routers/settings.py
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
|
from fastapi.responses import StreamingResponse, FileResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, date
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||||
|
from backend.services.database_backup import DatabaseBackupService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export-csv")
|
||||||
|
def export_roster_csv(db: Session = Depends(get_db)):
|
||||||
|
"""Export all roster units to CSV"""
|
||||||
|
units = db.query(RosterUnit).all()
|
||||||
|
|
||||||
|
# Create CSV in memory
|
||||||
|
output = io.StringIO()
|
||||||
|
fieldnames = [
|
||||||
|
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
|
||||||
|
'note', 'project_id', 'location', 'address', 'coordinates',
|
||||||
|
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
|
||||||
|
'ip_address', 'phone_number', 'hardware_model'
|
||||||
|
]
|
||||||
|
|
||||||
|
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
|
||||||
|
for unit in units:
|
||||||
|
writer.writerow({
|
||||||
|
'unit_id': unit.id,
|
||||||
|
'unit_type': unit.unit_type or '',
|
||||||
|
'device_type': unit.device_type or 'seismograph',
|
||||||
|
'deployed': 'true' if unit.deployed else 'false',
|
||||||
|
'retired': 'true' if unit.retired else 'false',
|
||||||
|
'note': unit.note or '',
|
||||||
|
'project_id': unit.project_id or '',
|
||||||
|
'location': unit.location or '',
|
||||||
|
'address': unit.address or '',
|
||||||
|
'coordinates': unit.coordinates or '',
|
||||||
|
'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
|
||||||
|
'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '',
|
||||||
|
'deployed_with_modem_id': unit.deployed_with_modem_id or '',
|
||||||
|
'ip_address': unit.ip_address or '',
|
||||||
|
'phone_number': unit.phone_number or '',
|
||||||
|
'hardware_model': unit.hardware_model or ''
|
||||||
|
})
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
filename = f"roster_export_{date.today().isoformat()}.csv"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(output.getvalue().encode('utf-8')),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def get_table_stats(db: Session = Depends(get_db)):
|
||||||
|
"""Get counts for all tables"""
|
||||||
|
roster_count = db.query(RosterUnit).count()
|
||||||
|
emitters_count = db.query(Emitter).count()
|
||||||
|
ignored_count = db.query(IgnoredUnit).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"roster": roster_count,
|
||||||
|
"emitters": emitters_count,
|
||||||
|
"ignored": ignored_count,
|
||||||
|
"total": roster_count + emitters_count + ignored_count
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roster-units")
|
||||||
|
def get_all_roster_units(db: Session = Depends(get_db)):
|
||||||
|
"""Get all roster units for management table"""
|
||||||
|
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"id": unit.id,
|
||||||
|
"device_type": unit.device_type or "seismograph",
|
||||||
|
"unit_type": unit.unit_type or "series3",
|
||||||
|
"deployed": unit.deployed,
|
||||||
|
"retired": unit.retired,
|
||||||
|
"note": unit.note or "",
|
||||||
|
"project_id": unit.project_id or "",
|
||||||
|
"location": unit.location or "",
|
||||||
|
"address": unit.address or "",
|
||||||
|
"coordinates": unit.coordinates or "",
|
||||||
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
||||||
|
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
|
||||||
|
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||||
|
"ip_address": unit.ip_address or "",
|
||||||
|
"phone_number": unit.phone_number or "",
|
||||||
|
"hardware_model": unit.hardware_model or "",
|
||||||
|
"slm_host": unit.slm_host or "",
|
||||||
|
"slm_tcp_port": unit.slm_tcp_port,
|
||||||
|
"slm_model": unit.slm_model or "",
|
||||||
|
"slm_serial_number": unit.slm_serial_number or "",
|
||||||
|
"slm_frequency_weighting": unit.slm_frequency_weighting or "",
|
||||||
|
"slm_time_weighting": unit.slm_time_weighting or "",
|
||||||
|
"slm_measurement_range": unit.slm_measurement_range or "",
|
||||||
|
"slm_last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||||
|
"last_updated": unit.last_updated.isoformat() if unit.last_updated else None
|
||||||
|
} for unit in units]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(date_str):
|
||||||
|
"""Helper function to parse date strings"""
|
||||||
|
if not date_str or not date_str.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str.strip(), "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-csv-replace")
|
||||||
|
async def import_csv_replace(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Replace all roster data with CSV import (atomic transaction).
|
||||||
|
Clears roster table first, then imports all rows from CSV.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not file.filename.endswith('.csv'):
|
||||||
|
raise HTTPException(status_code=400, detail="File must be a CSV")
|
||||||
|
|
||||||
|
# Read and parse CSV
|
||||||
|
contents = await file.read()
|
||||||
|
csv_text = contents.decode('utf-8')
|
||||||
|
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||||
|
|
||||||
|
# Parse all rows FIRST (fail fast before deletion)
|
||||||
|
parsed_units = []
|
||||||
|
for row_num, row in enumerate(csv_reader, start=2):
|
||||||
|
unit_id = row.get('unit_id', '').strip()
|
||||||
|
if not unit_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Row {row_num}: Missing required field unit_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse and validate dates
|
||||||
|
last_cal_date = parse_date(row.get('last_calibrated'))
|
||||||
|
next_cal_date = parse_date(row.get('next_calibration_due'))
|
||||||
|
|
||||||
|
parsed_units.append({
|
||||||
|
'id': unit_id,
|
||||||
|
'unit_type': row.get('unit_type', 'series3'),
|
||||||
|
'device_type': row.get('device_type', 'seismograph'),
|
||||||
|
'deployed': row.get('deployed', '').lower() in ('true', '1', 'yes'),
|
||||||
|
'retired': row.get('retired', '').lower() in ('true', '1', 'yes'),
|
||||||
|
'note': row.get('note', ''),
|
||||||
|
'project_id': row.get('project_id') or None,
|
||||||
|
'location': row.get('location') or None,
|
||||||
|
'address': row.get('address') or None,
|
||||||
|
'coordinates': row.get('coordinates') or None,
|
||||||
|
'last_calibrated': last_cal_date,
|
||||||
|
'next_calibration_due': next_cal_date,
|
||||||
|
'deployed_with_modem_id': row.get('deployed_with_modem_id') or None,
|
||||||
|
'ip_address': row.get('ip_address') or None,
|
||||||
|
'phone_number': row.get('phone_number') or None,
|
||||||
|
'hardware_model': row.get('hardware_model') or None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Atomic transaction: delete all, then insert all
|
||||||
|
try:
|
||||||
|
deleted_count = db.query(RosterUnit).delete()
|
||||||
|
|
||||||
|
for unit_data in parsed_units:
|
||||||
|
new_unit = RosterUnit(**unit_data, last_updated=datetime.utcnow())
|
||||||
|
db.add(new_unit)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Roster replaced successfully",
|
||||||
|
"deleted": deleted_count,
|
||||||
|
"added": len(parsed_units)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear-all")
|
||||||
|
def clear_all_data(db: Session = Depends(get_db)):
|
||||||
|
"""Clear all tables (roster, emitters, ignored)"""
|
||||||
|
try:
|
||||||
|
roster_count = db.query(RosterUnit).delete()
|
||||||
|
emitters_count = db.query(Emitter).delete()
|
||||||
|
ignored_count = db.query(IgnoredUnit).delete()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "All data cleared",
|
||||||
|
"deleted": {
|
||||||
|
"roster": roster_count,
|
||||||
|
"emitters": emitters_count,
|
||||||
|
"ignored": ignored_count,
|
||||||
|
"total": roster_count + emitters_count + ignored_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear-roster")
|
||||||
|
def clear_roster(db: Session = Depends(get_db)):
|
||||||
|
"""Clear roster table only"""
|
||||||
|
try:
|
||||||
|
count = db.query(RosterUnit).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Roster cleared", "deleted": count}
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear-emitters")
|
||||||
|
def clear_emitters(db: Session = Depends(get_db)):
|
||||||
|
"""Clear emitters table only"""
|
||||||
|
try:
|
||||||
|
count = db.query(Emitter).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Emitters cleared", "deleted": count}
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear-ignored")
|
||||||
|
def clear_ignored(db: Session = Depends(get_db)):
|
||||||
|
"""Clear ignored units table only"""
|
||||||
|
try:
|
||||||
|
count = db.query(IgnoredUnit).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Ignored units cleared", "deleted": count}
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# User Preferences Endpoints
|
||||||
|
|
||||||
|
class PreferencesUpdate(BaseModel):
|
||||||
|
"""Schema for updating user preferences (all fields optional)"""
|
||||||
|
timezone: Optional[str] = None
|
||||||
|
theme: Optional[str] = None
|
||||||
|
auto_refresh_interval: Optional[int] = None
|
||||||
|
date_format: Optional[str] = None
|
||||||
|
table_rows_per_page: Optional[int] = None
|
||||||
|
calibration_interval_days: Optional[int] = None
|
||||||
|
calibration_warning_days: Optional[int] = None
|
||||||
|
status_ok_threshold_hours: Optional[int] = None
|
||||||
|
status_pending_threshold_hours: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/preferences")
|
||||||
|
def get_preferences(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get user preferences. Creates default preferences if none exist.
|
||||||
|
"""
|
||||||
|
prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first()
|
||||||
|
|
||||||
|
if not prefs:
|
||||||
|
# Create default preferences
|
||||||
|
prefs = UserPreferences(id=1)
|
||||||
|
db.add(prefs)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(prefs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"timezone": prefs.timezone,
|
||||||
|
"theme": prefs.theme,
|
||||||
|
"auto_refresh_interval": prefs.auto_refresh_interval,
|
||||||
|
"date_format": prefs.date_format,
|
||||||
|
"table_rows_per_page": prefs.table_rows_per_page,
|
||||||
|
"calibration_interval_days": prefs.calibration_interval_days,
|
||||||
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||||
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/preferences")
|
||||||
|
def update_preferences(
|
||||||
|
updates: PreferencesUpdate,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update user preferences. Accepts partial updates.
|
||||||
|
Creates default preferences if none exist.
|
||||||
|
"""
|
||||||
|
prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first()
|
||||||
|
|
||||||
|
if not prefs:
|
||||||
|
# Create default preferences
|
||||||
|
prefs = UserPreferences(id=1)
|
||||||
|
db.add(prefs)
|
||||||
|
|
||||||
|
# Update only provided fields
|
||||||
|
update_data = updates.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(prefs, field, value)
|
||||||
|
|
||||||
|
prefs.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(prefs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Preferences updated successfully",
|
||||||
|
"timezone": prefs.timezone,
|
||||||
|
"theme": prefs.theme,
|
||||||
|
"auto_refresh_interval": prefs.auto_refresh_interval,
|
||||||
|
"date_format": prefs.date_format,
|
||||||
|
"table_rows_per_page": prefs.table_rows_per_page,
|
||||||
|
"calibration_interval_days": prefs.calibration_interval_days,
|
||||||
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||||
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Database Management Endpoints
|
||||||
|
|
||||||
|
backup_service = DatabaseBackupService()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/database/stats")
|
||||||
|
def get_database_stats():
|
||||||
|
"""Get current database statistics"""
|
||||||
|
try:
|
||||||
|
stats = backup_service.get_database_stats()
|
||||||
|
return stats
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get database stats: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/database/snapshot")
|
||||||
|
def create_database_snapshot(description: Optional[str] = None):
|
||||||
|
"""Create a full database snapshot"""
|
||||||
|
try:
|
||||||
|
snapshot = backup_service.create_snapshot(description=description)
|
||||||
|
return {
|
||||||
|
"message": "Snapshot created successfully",
|
||||||
|
"snapshot": snapshot
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Snapshot creation failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/database/snapshots")
|
||||||
|
def list_database_snapshots():
|
||||||
|
"""List all available database snapshots"""
|
||||||
|
try:
|
||||||
|
snapshots = backup_service.list_snapshots()
|
||||||
|
return {
|
||||||
|
"snapshots": snapshots,
|
||||||
|
"count": len(snapshots)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to list snapshots: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/database/snapshot/{filename}")
|
||||||
|
def download_snapshot(filename: str):
|
||||||
|
"""Download a specific snapshot file"""
|
||||||
|
try:
|
||||||
|
snapshot_path = backup_service.download_snapshot(filename)
|
||||||
|
return FileResponse(
|
||||||
|
path=str(snapshot_path),
|
||||||
|
filename=filename,
|
||||||
|
media_type="application/x-sqlite3"
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Snapshot {filename} not found")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Download failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/database/snapshot/{filename}")
|
||||||
|
def delete_database_snapshot(filename: str):
|
||||||
|
"""Delete a specific snapshot"""
|
||||||
|
try:
|
||||||
|
backup_service.delete_snapshot(filename)
|
||||||
|
return {
|
||||||
|
"message": f"Snapshot {filename} deleted successfully",
|
||||||
|
"filename": filename
|
||||||
|
}
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Snapshot {filename} not found")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreRequest(BaseModel):
|
||||||
|
"""Schema for restore request"""
|
||||||
|
filename: str
|
||||||
|
create_backup: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/database/restore")
|
||||||
|
def restore_database(request: RestoreRequest, db: Session = Depends(get_db)):
|
||||||
|
"""Restore database from a snapshot"""
|
||||||
|
try:
|
||||||
|
# Close the database connection before restoring
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
result = backup_service.restore_snapshot(
|
||||||
|
filename=request.filename,
|
||||||
|
create_backup_before_restore=request.create_backup
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Snapshot {request.filename} not found")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Restore failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/database/upload-snapshot")
|
||||||
|
async def upload_snapshot(file: UploadFile = File(...)):
|
||||||
|
"""Upload a snapshot file to the backups directory"""
|
||||||
|
if not file.filename.endswith('.db'):
|
||||||
|
raise HTTPException(status_code=400, detail="File must be a .db file")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Save uploaded file to backups directory
|
||||||
|
backups_dir = Path("./data/backups")
|
||||||
|
backups_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||||
|
uploaded_filename = f"snapshot_uploaded_{timestamp}.db"
|
||||||
|
file_path = backups_dir / uploaded_filename
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
with open(file_path, "wb") as buffer:
|
||||||
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
|
||||||
|
# Create metadata
|
||||||
|
metadata = {
|
||||||
|
"filename": uploaded_filename,
|
||||||
|
"created_at": timestamp,
|
||||||
|
"created_at_iso": datetime.utcnow().isoformat(),
|
||||||
|
"description": f"Uploaded: {file.filename}",
|
||||||
|
"size_bytes": file_path.stat().st_size,
|
||||||
|
"size_mb": round(file_path.stat().st_size / (1024 * 1024), 2),
|
||||||
|
"type": "uploaded"
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata_path = backups_dir / f"{uploaded_filename}.meta.json"
|
||||||
|
import json
|
||||||
|
with open(metadata_path, 'w') as f:
|
||||||
|
json.dump(metadata, f, indent=2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Snapshot uploaded successfully",
|
||||||
|
"snapshot": metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
||||||
44
app/seismo/routers/units.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["units"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}")
|
||||||
|
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns detailed data for a single unit.
|
||||||
|
"""
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
|
||||||
|
if unit_id not in snapshot["units"]:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
|
unit_data = snapshot["units"][unit_id]
|
||||||
|
|
||||||
|
# Mock coordinates for now (will be replaced with real data)
|
||||||
|
mock_coords = {
|
||||||
|
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
|
||||||
|
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
|
||||||
|
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
|
||||||
|
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
|
||||||
|
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
|
||||||
|
}
|
||||||
|
|
||||||
|
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": unit_id,
|
||||||
|
"status": unit_data["status"],
|
||||||
|
"age": unit_data["age"],
|
||||||
|
"last_seen": unit_data["last"],
|
||||||
|
"last_file": unit_data.get("fname", ""),
|
||||||
|
"deployed": unit_data["deployed"],
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"coordinates": coords
|
||||||
|
}
|
||||||
286
app/seismo/routes.py
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import Emitter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# Helper function to detect unit type from unit ID
|
||||||
|
def detect_unit_type(unit_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Automatically detect if a unit is Series 3 or Series 4 based on ID pattern.
|
||||||
|
|
||||||
|
Series 4 (Micromate) units have IDs starting with "UM" followed by digits (e.g., UM11719)
|
||||||
|
Series 3 units typically have other patterns
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"series4" if the unit ID matches Micromate pattern (UM#####)
|
||||||
|
"series3" otherwise
|
||||||
|
"""
|
||||||
|
if not unit_id:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
# Series 4 (Micromate) pattern: UM followed by digits
|
||||||
|
if unit_id.upper().startswith("UM") and len(unit_id) > 2:
|
||||||
|
# Check if remaining characters after "UM" are digits
|
||||||
|
rest = unit_id[2:]
|
||||||
|
if rest.isdigit():
|
||||||
|
return "series4"
|
||||||
|
|
||||||
|
# Default to series3 for other patterns
|
||||||
|
return "series3"
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic schemas for request/response validation
|
||||||
|
class EmitterReport(BaseModel):
|
||||||
|
unit: str
|
||||||
|
unit_type: str
|
||||||
|
timestamp: str
|
||||||
|
file: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmitterResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
unit_type: str
|
||||||
|
last_seen: datetime
|
||||||
|
last_file: str
|
||||||
|
status: str
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/emitters/report", status_code=200)
|
||||||
|
def report_emitter(report: EmitterReport, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Endpoint for emitters to report their status.
|
||||||
|
Creates a new emitter if it doesn't exist, or updates an existing one.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Parse the timestamp
|
||||||
|
timestamp = datetime.fromisoformat(report.timestamp.replace('Z', '+00:00'))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid timestamp format")
|
||||||
|
|
||||||
|
# Check if emitter already exists
|
||||||
|
emitter = db.query(Emitter).filter(Emitter.id == report.unit).first()
|
||||||
|
|
||||||
|
if emitter:
|
||||||
|
# Update existing emitter
|
||||||
|
emitter.unit_type = report.unit_type
|
||||||
|
emitter.last_seen = timestamp
|
||||||
|
emitter.last_file = report.file
|
||||||
|
emitter.status = report.status
|
||||||
|
else:
|
||||||
|
# Create new emitter
|
||||||
|
emitter = Emitter(
|
||||||
|
id=report.unit,
|
||||||
|
unit_type=report.unit_type,
|
||||||
|
last_seen=timestamp,
|
||||||
|
last_file=report.file,
|
||||||
|
status=report.status
|
||||||
|
)
|
||||||
|
db.add(emitter)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(emitter)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Emitter report received",
|
||||||
|
"unit": emitter.id,
|
||||||
|
"status": emitter.status
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fleet/status", response_model=List[EmitterResponse])
|
||||||
|
def get_fleet_status(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns a list of all emitters and their current status.
|
||||||
|
"""
|
||||||
|
emitters = db.query(Emitter).all()
|
||||||
|
return emitters
|
||||||
|
|
||||||
|
# series3v1.1 Standardized Heartbeat Schema (multi-unit)
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
@router.post("/api/series3/heartbeat", status_code=200)
|
||||||
|
async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Accepts a full telemetry payload from the Series3 emitter.
|
||||||
|
Updates or inserts each unit into the database.
|
||||||
|
"""
|
||||||
|
payload = await request.json()
|
||||||
|
|
||||||
|
source = payload.get("source_id")
|
||||||
|
units = payload.get("units", [])
|
||||||
|
|
||||||
|
print("\n=== Series 3 Heartbeat ===")
|
||||||
|
print("Source:", source)
|
||||||
|
print("Units received:", len(units))
|
||||||
|
print("==========================\n")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for u in units:
|
||||||
|
uid = u.get("unit_id")
|
||||||
|
last_event_time = u.get("last_event_time")
|
||||||
|
event_meta = u.get("event_metadata", {})
|
||||||
|
age_minutes = u.get("age_minutes")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if last_event_time:
|
||||||
|
ts = datetime.fromisoformat(last_event_time.replace("Z", "+00:00"))
|
||||||
|
else:
|
||||||
|
ts = None
|
||||||
|
except:
|
||||||
|
ts = None
|
||||||
|
|
||||||
|
# Pull from DB
|
||||||
|
emitter = db.query(Emitter).filter(Emitter.id == uid).first()
|
||||||
|
|
||||||
|
# File name (from event_metadata)
|
||||||
|
last_file = event_meta.get("file_name")
|
||||||
|
status = "Unknown"
|
||||||
|
|
||||||
|
# Determine status based on age
|
||||||
|
if age_minutes is None:
|
||||||
|
status = "Missing"
|
||||||
|
elif age_minutes > 24 * 60:
|
||||||
|
status = "Missing"
|
||||||
|
elif age_minutes > 12 * 60:
|
||||||
|
status = "Pending"
|
||||||
|
else:
|
||||||
|
status = "OK"
|
||||||
|
|
||||||
|
if emitter:
|
||||||
|
# Update existing
|
||||||
|
emitter.last_seen = ts
|
||||||
|
emitter.last_file = last_file
|
||||||
|
emitter.status = status
|
||||||
|
# Update unit_type if it was incorrectly classified
|
||||||
|
detected_type = detect_unit_type(uid)
|
||||||
|
if emitter.unit_type != detected_type:
|
||||||
|
emitter.unit_type = detected_type
|
||||||
|
else:
|
||||||
|
# Insert new - auto-detect unit type from ID
|
||||||
|
detected_type = detect_unit_type(uid)
|
||||||
|
emitter = Emitter(
|
||||||
|
id=uid,
|
||||||
|
unit_type=detected_type,
|
||||||
|
last_seen=ts,
|
||||||
|
last_file=last_file,
|
||||||
|
status=status
|
||||||
|
)
|
||||||
|
db.add(emitter)
|
||||||
|
|
||||||
|
results.append({"unit": uid, "status": status})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Heartbeat processed",
|
||||||
|
"source": source,
|
||||||
|
"units_processed": len(results),
|
||||||
|
"results": results
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# series4 (Micromate) Standardized Heartbeat Schema
|
||||||
|
@router.post("/api/series4/heartbeat", status_code=200)
|
||||||
|
async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Accepts a full telemetry payload from the Series4 (Micromate) emitter.
|
||||||
|
Updates or inserts each unit into the database.
|
||||||
|
|
||||||
|
Expected payload:
|
||||||
|
{
|
||||||
|
"source": "series4_emitter",
|
||||||
|
"generated_at": "2025-12-04T20:01:00",
|
||||||
|
"units": [
|
||||||
|
{
|
||||||
|
"unit_id": "UM11719",
|
||||||
|
"type": "micromate",
|
||||||
|
"project_hint": "Clearwater - ECMS 57940",
|
||||||
|
"last_call": "2025-12-04T19:30:42",
|
||||||
|
"status": "OK",
|
||||||
|
"age_days": 0.04,
|
||||||
|
"age_hours": 0.9,
|
||||||
|
"mlg_path": "C:\\THORDATA\\..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
payload = await request.json()
|
||||||
|
|
||||||
|
source = payload.get("source", "series4_emitter")
|
||||||
|
units = payload.get("units", [])
|
||||||
|
|
||||||
|
print("\n=== Series 4 Heartbeat ===")
|
||||||
|
print("Source:", source)
|
||||||
|
print("Units received:", len(units))
|
||||||
|
print("==========================\n")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for u in units:
|
||||||
|
uid = u.get("unit_id")
|
||||||
|
last_call_str = u.get("last_call")
|
||||||
|
status = u.get("status", "Unknown")
|
||||||
|
mlg_path = u.get("mlg_path")
|
||||||
|
project_hint = u.get("project_hint")
|
||||||
|
|
||||||
|
# Parse last_call timestamp
|
||||||
|
try:
|
||||||
|
if last_call_str:
|
||||||
|
ts = datetime.fromisoformat(last_call_str.replace("Z", "+00:00"))
|
||||||
|
else:
|
||||||
|
ts = None
|
||||||
|
except:
|
||||||
|
ts = None
|
||||||
|
|
||||||
|
# Pull from DB
|
||||||
|
emitter = db.query(Emitter).filter(Emitter.id == uid).first()
|
||||||
|
|
||||||
|
if emitter:
|
||||||
|
# Update existing
|
||||||
|
emitter.last_seen = ts
|
||||||
|
emitter.last_file = mlg_path
|
||||||
|
emitter.status = status
|
||||||
|
# Update unit_type if it was incorrectly classified
|
||||||
|
detected_type = detect_unit_type(uid)
|
||||||
|
if emitter.unit_type != detected_type:
|
||||||
|
emitter.unit_type = detected_type
|
||||||
|
# Optionally update notes with project hint if it exists
|
||||||
|
if project_hint and not emitter.notes:
|
||||||
|
emitter.notes = f"Project: {project_hint}"
|
||||||
|
else:
|
||||||
|
# Insert new - auto-detect unit type from ID
|
||||||
|
detected_type = detect_unit_type(uid)
|
||||||
|
notes = f"Project: {project_hint}" if project_hint else None
|
||||||
|
emitter = Emitter(
|
||||||
|
id=uid,
|
||||||
|
unit_type=detected_type,
|
||||||
|
last_seen=ts,
|
||||||
|
last_file=mlg_path,
|
||||||
|
status=status,
|
||||||
|
notes=notes
|
||||||
|
)
|
||||||
|
db.add(emitter)
|
||||||
|
|
||||||
|
results.append({"unit": uid, "status": status})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Heartbeat processed",
|
||||||
|
"source": source,
|
||||||
|
"units_processed": len(results),
|
||||||
|
"results": results
|
||||||
|
}
|
||||||
0
app/seismo/services/__init__.py
Normal file
145
app/seismo/services/backup_scheduler.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Automatic Database Backup Scheduler
|
||||||
|
Handles scheduled automatic backups of the database
|
||||||
|
"""
|
||||||
|
|
||||||
|
import schedule
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from backend.services.database_backup import DatabaseBackupService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupScheduler:
|
||||||
|
"""Manages automatic database backups on a schedule"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = "./data/seismo_fleet.db", backups_dir: str = "./data/backups"):
|
||||||
|
self.backup_service = DatabaseBackupService(db_path=db_path, backups_dir=backups_dir)
|
||||||
|
self.scheduler_thread: Optional[threading.Thread] = None
|
||||||
|
self.is_running = False
|
||||||
|
|
||||||
|
# Default settings
|
||||||
|
self.backup_interval_hours = 24 # Daily backups
|
||||||
|
self.keep_count = 10 # Keep last 10 backups
|
||||||
|
self.enabled = False
|
||||||
|
|
||||||
|
def configure(self, interval_hours: int = 24, keep_count: int = 10, enabled: bool = True):
|
||||||
|
"""
|
||||||
|
Configure backup scheduler settings
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interval_hours: Hours between automatic backups
|
||||||
|
keep_count: Number of backups to retain
|
||||||
|
enabled: Whether automatic backups are enabled
|
||||||
|
"""
|
||||||
|
self.backup_interval_hours = interval_hours
|
||||||
|
self.keep_count = keep_count
|
||||||
|
self.enabled = enabled
|
||||||
|
|
||||||
|
logger.info(f"Backup scheduler configured: interval={interval_hours}h, keep={keep_count}, enabled={enabled}")
|
||||||
|
|
||||||
|
def create_automatic_backup(self):
|
||||||
|
"""Create an automatic backup and cleanup old ones"""
|
||||||
|
if not self.enabled:
|
||||||
|
logger.info("Automatic backups are disabled, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
description = f"Automatic backup - {timestamp}"
|
||||||
|
|
||||||
|
logger.info("Creating automatic backup...")
|
||||||
|
snapshot = self.backup_service.create_snapshot(description=description)
|
||||||
|
|
||||||
|
logger.info(f"Automatic backup created: {snapshot['filename']} ({snapshot['size_mb']} MB)")
|
||||||
|
|
||||||
|
# Cleanup old backups
|
||||||
|
cleanup_result = self.backup_service.cleanup_old_snapshots(keep_count=self.keep_count)
|
||||||
|
if cleanup_result['deleted'] > 0:
|
||||||
|
logger.info(f"Cleaned up {cleanup_result['deleted']} old snapshots")
|
||||||
|
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Automatic backup failed: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start the backup scheduler in a background thread"""
|
||||||
|
if self.is_running:
|
||||||
|
logger.warning("Backup scheduler is already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.enabled:
|
||||||
|
logger.info("Backup scheduler is disabled, not starting")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Starting backup scheduler (every {self.backup_interval_hours} hours)")
|
||||||
|
|
||||||
|
# Clear any existing scheduled jobs
|
||||||
|
schedule.clear()
|
||||||
|
|
||||||
|
# Schedule the backup job
|
||||||
|
schedule.every(self.backup_interval_hours).hours.do(self.create_automatic_backup)
|
||||||
|
|
||||||
|
# Also run immediately on startup
|
||||||
|
self.create_automatic_backup()
|
||||||
|
|
||||||
|
# Start the scheduler thread
|
||||||
|
self.is_running = True
|
||||||
|
self.scheduler_thread = threading.Thread(target=self._run_scheduler, daemon=True)
|
||||||
|
self.scheduler_thread.start()
|
||||||
|
|
||||||
|
logger.info("Backup scheduler started successfully")
|
||||||
|
|
||||||
|
def _run_scheduler(self):
|
||||||
|
"""Internal method to run the scheduler loop"""
|
||||||
|
while self.is_running:
|
||||||
|
schedule.run_pending()
|
||||||
|
time.sleep(60) # Check every minute
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the backup scheduler"""
|
||||||
|
if not self.is_running:
|
||||||
|
logger.warning("Backup scheduler is not running")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Stopping backup scheduler...")
|
||||||
|
self.is_running = False
|
||||||
|
schedule.clear()
|
||||||
|
|
||||||
|
if self.scheduler_thread:
|
||||||
|
self.scheduler_thread.join(timeout=5)
|
||||||
|
|
||||||
|
logger.info("Backup scheduler stopped")
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
"""Get current scheduler status"""
|
||||||
|
next_run = None
|
||||||
|
if self.is_running and schedule.jobs:
|
||||||
|
next_run = schedule.jobs[0].next_run.isoformat() if schedule.jobs[0].next_run else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"running": self.is_running,
|
||||||
|
"interval_hours": self.backup_interval_hours,
|
||||||
|
"keep_count": self.keep_count,
|
||||||
|
"next_run": next_run
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Global scheduler instance
|
||||||
|
_scheduler_instance: Optional[BackupScheduler] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_backup_scheduler() -> BackupScheduler:
|
||||||
|
"""Get or create the global backup scheduler instance"""
|
||||||
|
global _scheduler_instance
|
||||||
|
if _scheduler_instance is None:
|
||||||
|
_scheduler_instance = BackupScheduler()
|
||||||
|
return _scheduler_instance
|
||||||
192
app/seismo/services/database_backup.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
Database Backup and Restore Service
|
||||||
|
Handles full database snapshots, restoration, and remote synchronization
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseBackupService:
|
||||||
|
"""Manages database backup operations"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = "./data/seismo_fleet.db", backups_dir: str = "./data/backups"):
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
self.backups_dir = Path(backups_dir)
|
||||||
|
self.backups_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def create_snapshot(self, description: Optional[str] = None) -> Dict:
|
||||||
|
"""
|
||||||
|
Create a full database snapshot using SQLite backup API
|
||||||
|
Returns snapshot metadata
|
||||||
|
"""
|
||||||
|
if not self.db_path.exists():
|
||||||
|
raise FileNotFoundError(f"Database not found at {self.db_path}")
|
||||||
|
|
||||||
|
# Generate snapshot filename with timestamp
|
||||||
|
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||||
|
snapshot_name = f"snapshot_{timestamp}.db"
|
||||||
|
snapshot_path = self.backups_dir / snapshot_name
|
||||||
|
|
||||||
|
# Get database size before backup
|
||||||
|
db_size = self.db_path.stat().st_size
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use SQLite backup API for safe backup (handles concurrent access)
|
||||||
|
source_conn = sqlite3.connect(str(self.db_path))
|
||||||
|
dest_conn = sqlite3.connect(str(snapshot_path))
|
||||||
|
|
||||||
|
# Perform the backup
|
||||||
|
with dest_conn:
|
||||||
|
source_conn.backup(dest_conn)
|
||||||
|
|
||||||
|
source_conn.close()
|
||||||
|
dest_conn.close()
|
||||||
|
|
||||||
|
# Create metadata
|
||||||
|
metadata = {
|
||||||
|
"filename": snapshot_name,
|
||||||
|
"created_at": timestamp,
|
||||||
|
"created_at_iso": datetime.utcnow().isoformat(),
|
||||||
|
"description": description or "Manual snapshot",
|
||||||
|
"size_bytes": snapshot_path.stat().st_size,
|
||||||
|
"size_mb": round(snapshot_path.stat().st_size / (1024 * 1024), 2),
|
||||||
|
"original_db_size_bytes": db_size,
|
||||||
|
"type": "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save metadata as JSON sidecar file
|
||||||
|
metadata_path = self.backups_dir / f"{snapshot_name}.meta.json"
|
||||||
|
with open(metadata_path, 'w') as f:
|
||||||
|
json.dump(metadata, f, indent=2)
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Clean up partial snapshot if it exists
|
||||||
|
if snapshot_path.exists():
|
||||||
|
snapshot_path.unlink()
|
||||||
|
raise Exception(f"Snapshot creation failed: {str(e)}")
|
||||||
|
|
||||||
|
def list_snapshots(self) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
List all available snapshots with metadata
|
||||||
|
Returns list sorted by creation date (newest first)
|
||||||
|
"""
|
||||||
|
snapshots = []
|
||||||
|
|
||||||
|
for db_file in sorted(self.backups_dir.glob("snapshot_*.db"), reverse=True):
|
||||||
|
metadata_file = self.backups_dir / f"{db_file.name}.meta.json"
|
||||||
|
|
||||||
|
if metadata_file.exists():
|
||||||
|
with open(metadata_file, 'r') as f:
|
||||||
|
metadata = json.load(f)
|
||||||
|
else:
|
||||||
|
# Fallback for legacy snapshots without metadata
|
||||||
|
stat_info = db_file.stat()
|
||||||
|
metadata = {
|
||||||
|
"filename": db_file.name,
|
||||||
|
"created_at": datetime.fromtimestamp(stat_info.st_mtime).strftime("%Y%m%d_%H%M%S"),
|
||||||
|
"created_at_iso": datetime.fromtimestamp(stat_info.st_mtime).isoformat(),
|
||||||
|
"description": "Legacy snapshot",
|
||||||
|
"size_bytes": stat_info.st_size,
|
||||||
|
"size_mb": round(stat_info.st_size / (1024 * 1024), 2),
|
||||||
|
"type": "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots.append(metadata)
|
||||||
|
|
||||||
|
return snapshots
|
||||||
|
|
||||||
|
def delete_snapshot(self, filename: str) -> bool:
|
||||||
|
"""Delete a snapshot and its metadata"""
|
||||||
|
snapshot_path = self.backups_dir / filename
|
||||||
|
metadata_path = self.backups_dir / f"{filename}.meta.json"
|
||||||
|
|
||||||
|
if not snapshot_path.exists():
|
||||||
|
raise FileNotFoundError(f"Snapshot {filename} not found")
|
||||||
|
|
||||||
|
snapshot_path.unlink()
|
||||||
|
if metadata_path.exists():
|
||||||
|
metadata_path.unlink()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def restore_snapshot(self, filename: str, create_backup_before_restore: bool = True) -> Dict:
|
||||||
|
"""
|
||||||
|
Restore database from a snapshot
|
||||||
|
Creates a safety backup before restoring if requested
|
||||||
|
"""
|
||||||
|
snapshot_path = self.backups_dir / filename
|
||||||
|
|
||||||
|
if not snapshot_path.exists():
|
||||||
|
raise FileNotFoundError(f"Snapshot {filename} not found")
|
||||||
|
|
||||||
|
if not self.db_path.exists():
|
||||||
|
raise FileNotFoundError(f"Database not found at {self.db_path}")
|
||||||
|
|
||||||
|
backup_info = None
|
||||||
|
|
||||||
|
# Create safety backup before restore
|
||||||
|
if create_backup_before_restore:
|
||||||
|
backup_info = self.create_snapshot(description="Auto-backup before restore")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Replace database file
|
||||||
|
shutil.copy2(str(snapshot_path), str(self.db_path))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Database restored successfully",
|
||||||
|
"restored_from": filename,
|
||||||
|
"restored_at": datetime.utcnow().isoformat(),
|
||||||
|
"backup_created": backup_info["filename"] if backup_info else None
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Restore failed: {str(e)}")
|
||||||
|
|
||||||
|
def get_database_stats(self) -> Dict:
|
||||||
|
"""Get statistics about the current database"""
|
||||||
|
if not self.db_path.exists():
|
||||||
|
return {"error": "Database not found"}
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(self.db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get table counts
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||||
|
tables = cursor.fetchall()
|
||||||
|
|
||||||
|
table_stats = {}
|
||||||
|
total_rows = 0
|
||||||
|
|
||||||
|
for (table_name,) in tables:
|
||||||
|
cursor.execute(f"SELECT COUNT(*) FROM {table_name}")
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
table_stats[table_name] = count
|
||||||
|
total_rows += count
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
db_size = self.db_path.stat().st_size
|
||||||
|
|
||||||
|
return {
|
||||||
|
"database_path": str(self.db_path),
|
||||||
|
"size_bytes": db_size,
|
||||||
|
"size_mb": round(db_size / (1024 * 1024), 2),
|
||||||
|
"total_rows": total_rows,
|
||||||
|
"tables": table_stats,
|
||||||
|
"last_modified": datetime.fromtimestamp(self.db_path.stat().st_mtime).isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
def download_snapshot(self, filename: str) -> Path:
|
||||||
|
"""Get the file path for downloading a snapshot"""
|
||||||
|
snapshot_path = self.backups_dir / filename
|
||||||
|
if not snapshot_path.exists():
|
||||||
|
raise FileNotFoundError(f"Snapshot {filename} not found")
|
||||||
|
return snapshot_path
|
||||||
191
app/seismo/services/snapshot.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.database import get_db_session
|
||||||
|
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_utc(dt):
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def format_age(last_seen):
|
||||||
|
if not last_seen:
|
||||||
|
return "N/A"
|
||||||
|
last_seen = ensure_utc(last_seen)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
diff = now - last_seen
|
||||||
|
hours = diff.total_seconds() // 3600
|
||||||
|
mins = (diff.total_seconds() % 3600) // 60
|
||||||
|
return f"{int(hours)}h {int(mins)}m"
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_status(last_seen, status_ok_threshold=12, status_pending_threshold=24):
|
||||||
|
"""
|
||||||
|
Calculate status based on how long ago the unit was last seen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
last_seen: datetime of last seen (UTC)
|
||||||
|
status_ok_threshold: hours before status becomes Pending (default 12)
|
||||||
|
status_pending_threshold: hours before status becomes Missing (default 24)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"OK", "Pending", or "Missing"
|
||||||
|
"""
|
||||||
|
if not last_seen:
|
||||||
|
return "Missing"
|
||||||
|
|
||||||
|
last_seen = ensure_utc(last_seen)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
hours_ago = (now - last_seen).total_seconds() / 3600
|
||||||
|
|
||||||
|
if hours_ago > status_pending_threshold:
|
||||||
|
return "Missing"
|
||||||
|
elif hours_ago > status_ok_threshold:
|
||||||
|
return "Pending"
|
||||||
|
else:
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def emit_status_snapshot():
|
||||||
|
"""
|
||||||
|
Merge roster (what we *intend*) with emitter data (what is *actually happening*).
|
||||||
|
Status is recalculated based on current time to ensure accuracy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
db = get_db_session()
|
||||||
|
try:
|
||||||
|
# Get user preferences for status thresholds
|
||||||
|
from backend.models import UserPreferences
|
||||||
|
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
||||||
|
status_ok_threshold = prefs.status_ok_threshold_hours if prefs else 12
|
||||||
|
status_pending_threshold = prefs.status_pending_threshold_hours if prefs else 24
|
||||||
|
|
||||||
|
roster = {r.id: r for r in db.query(RosterUnit).all()}
|
||||||
|
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||||
|
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
||||||
|
|
||||||
|
units = {}
|
||||||
|
|
||||||
|
# --- Merge roster entries first ---
|
||||||
|
for unit_id, r in roster.items():
|
||||||
|
e = emitters.get(unit_id)
|
||||||
|
if r.retired:
|
||||||
|
# Retired units get separated later
|
||||||
|
status = "Retired"
|
||||||
|
age = "N/A"
|
||||||
|
last_seen = None
|
||||||
|
fname = ""
|
||||||
|
else:
|
||||||
|
if e:
|
||||||
|
last_seen = ensure_utc(e.last_seen)
|
||||||
|
# RECALCULATE status based on current time, not stored value
|
||||||
|
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||||
|
age = format_age(last_seen)
|
||||||
|
fname = e.last_file
|
||||||
|
else:
|
||||||
|
# Rostered but no emitter data
|
||||||
|
status = "Missing"
|
||||||
|
last_seen = None
|
||||||
|
age = "N/A"
|
||||||
|
fname = ""
|
||||||
|
|
||||||
|
units[unit_id] = {
|
||||||
|
"id": unit_id,
|
||||||
|
"status": status,
|
||||||
|
"age": age,
|
||||||
|
"last": last_seen.isoformat() if last_seen else None,
|
||||||
|
"fname": fname,
|
||||||
|
"deployed": r.deployed,
|
||||||
|
"note": r.note or "",
|
||||||
|
"retired": r.retired,
|
||||||
|
# Device type and type-specific fields
|
||||||
|
"device_type": r.device_type or "seismograph",
|
||||||
|
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||||
|
"next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None,
|
||||||
|
"deployed_with_modem_id": r.deployed_with_modem_id,
|
||||||
|
"ip_address": r.ip_address,
|
||||||
|
"phone_number": r.phone_number,
|
||||||
|
"hardware_model": r.hardware_model,
|
||||||
|
# Location for mapping
|
||||||
|
"location": r.location or "",
|
||||||
|
"address": r.address or "",
|
||||||
|
"coordinates": r.coordinates or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Add unexpected emitter-only units ---
|
||||||
|
for unit_id, e in emitters.items():
|
||||||
|
if unit_id not in roster:
|
||||||
|
last_seen = ensure_utc(e.last_seen)
|
||||||
|
# RECALCULATE status for unknown units too
|
||||||
|
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||||
|
units[unit_id] = {
|
||||||
|
"id": unit_id,
|
||||||
|
"status": status,
|
||||||
|
"age": format_age(last_seen),
|
||||||
|
"last": last_seen.isoformat(),
|
||||||
|
"fname": e.last_file,
|
||||||
|
"deployed": False, # default
|
||||||
|
"note": "",
|
||||||
|
"retired": False,
|
||||||
|
# Device type and type-specific fields (defaults for unknown units)
|
||||||
|
"device_type": "seismograph", # default
|
||||||
|
"last_calibrated": None,
|
||||||
|
"next_calibration_due": None,
|
||||||
|
"deployed_with_modem_id": None,
|
||||||
|
"ip_address": None,
|
||||||
|
"phone_number": None,
|
||||||
|
"hardware_model": None,
|
||||||
|
# Location fields
|
||||||
|
"location": "",
|
||||||
|
"address": "",
|
||||||
|
"coordinates": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Separate buckets for UI
|
||||||
|
active_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if not u["retired"] and u["deployed"] and uid not in ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
benched_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if not u["retired"] and not u["deployed"] and uid not in ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
retired_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if u["retired"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unknown units - emitters that aren't in the roster and aren't ignored
|
||||||
|
unknown_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if uid not in roster and uid not in ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"units": units,
|
||||||
|
"active": active_units,
|
||||||
|
"benched": benched_units,
|
||||||
|
"retired": retired_units,
|
||||||
|
"unknown": unknown_units,
|
||||||
|
"summary": {
|
||||||
|
"total": len(active_units) + len(benched_units),
|
||||||
|
"active": len(active_units),
|
||||||
|
"benched": len(benched_units),
|
||||||
|
"retired": len(retired_units),
|
||||||
|
"unknown": len(unknown_units),
|
||||||
|
# Status counts only for deployed units (active_units)
|
||||||
|
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
|
||||||
|
"pending": sum(1 for u in active_units.values() if u["status"] == "Pending"),
|
||||||
|
"missing": sum(1 for u in active_units.values() if u["status"] == "Missing"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
0
app/slm/__init__.py
Normal file
36
app/slm/database.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Sound Level Meter feature module database connection
|
||||||
|
"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure data directory exists
|
||||||
|
os.makedirs("data", exist_ok=True)
|
||||||
|
|
||||||
|
# For now, we'll use the shared database (seismo_fleet.db) until we migrate
|
||||||
|
# TODO: Migrate to slm.db
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Dependency for database sessions"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_session():
|
||||||
|
"""Get a database session directly (not as a dependency)"""
|
||||||
|
return SessionLocal()
|
||||||
10
app/slm/models.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Sound Level Meter feature module models
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, String, Integer, Boolean, DateTime, Float
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# TODO: When we split databases, SLM-specific models will go here
|
||||||
|
# For now, SLM data is in the shared seismo_fleet.db database
|
||||||
0
app/slm/routers/__init__.py
Normal file
328
app/slm/routers/dashboard.py
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
"""
|
||||||
|
SLM Dashboard Router
|
||||||
|
|
||||||
|
Provides API endpoints for the Sound Level Meters dashboard page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends, Query
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import RosterUnit
|
||||||
|
from backend.routers.roster_edit import sync_slm_to_slmm_cache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
# SLMM backend URL - configurable via environment variable
|
||||||
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_class=HTMLResponse)
|
||||||
|
async def get_slm_stats(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get summary statistics for SLM dashboard.
|
||||||
|
Returns HTML partial with stat cards.
|
||||||
|
"""
|
||||||
|
# Query all SLMs
|
||||||
|
all_slms = db.query(RosterUnit).filter_by(device_type="sound_level_meter").all()
|
||||||
|
|
||||||
|
# Count deployed vs benched
|
||||||
|
deployed_count = sum(1 for slm in all_slms if slm.deployed and not slm.retired)
|
||||||
|
benched_count = sum(1 for slm in all_slms if not slm.deployed and not slm.retired)
|
||||||
|
retired_count = sum(1 for slm in all_slms if slm.retired)
|
||||||
|
|
||||||
|
# Count recently active (checked in last hour)
|
||||||
|
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
||||||
|
active_count = sum(1 for slm in all_slms
|
||||||
|
if slm.slm_last_check and slm.slm_last_check > one_hour_ago)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/slm_stats.html", {
|
||||||
|
"request": request,
|
||||||
|
"total_count": len(all_slms),
|
||||||
|
"deployed_count": deployed_count,
|
||||||
|
"benched_count": benched_count,
|
||||||
|
"active_count": active_count,
|
||||||
|
"retired_count": retired_count
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/units", response_class=HTMLResponse)
|
||||||
|
async def get_slm_units(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
search: str = Query(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get list of SLM units for the sidebar.
|
||||||
|
Returns HTML partial with unit cards.
|
||||||
|
"""
|
||||||
|
query = db.query(RosterUnit).filter_by(device_type="sound_level_meter")
|
||||||
|
|
||||||
|
# Filter by search term if provided
|
||||||
|
if search:
|
||||||
|
search_term = f"%{search}%"
|
||||||
|
query = query.filter(
|
||||||
|
(RosterUnit.id.like(search_term)) |
|
||||||
|
(RosterUnit.slm_model.like(search_term)) |
|
||||||
|
(RosterUnit.address.like(search_term))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only show deployed units by default
|
||||||
|
units = query.filter_by(deployed=True, retired=False).order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
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(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get live view panel for a specific SLM unit.
|
||||||
|
Returns HTML partial with live metrics and chart.
|
||||||
|
"""
|
||||||
|
# Get unit from database
|
||||||
|
unit = 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"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get modem information if assigned
|
||||||
|
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:
|
||||||
|
modem_ip = modem.ip_address
|
||||||
|
else:
|
||||||
|
logger.warning(f"SLM {unit_id} is assigned to modem {unit.deployed_with_modem_id} but modem not found")
|
||||||
|
|
||||||
|
# Fallback to direct slm_host if no modem assigned (backward compatibility)
|
||||||
|
if not modem_ip and unit.slm_host:
|
||||||
|
modem_ip = unit.slm_host
|
||||||
|
logger.info(f"Using legacy slm_host for {unit_id}: {modem_ip}")
|
||||||
|
|
||||||
|
# Try to get current status from SLMM
|
||||||
|
current_status = None
|
||||||
|
measurement_state = None
|
||||||
|
is_measuring = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
# Get measurement state
|
||||||
|
state_response = await client.get(
|
||||||
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||||
|
)
|
||||||
|
if state_response.status_code == 200:
|
||||||
|
state_data = state_response.json()
|
||||||
|
measurement_state = state_data.get("measurement_state", "Unknown")
|
||||||
|
is_measuring = state_data.get("is_measuring", False)
|
||||||
|
|
||||||
|
# Get live status
|
||||||
|
status_response = await client.get(
|
||||||
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
||||||
|
)
|
||||||
|
if status_response.status_code == 200:
|
||||||
|
status_data = status_response.json()
|
||||||
|
current_status = status_data.get("data", {})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get status for {unit_id}: {e}")
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/slm_live_view.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit": unit,
|
||||||
|
"modem": modem,
|
||||||
|
"modem_ip": modem_ip,
|
||||||
|
"current_status": current_status,
|
||||||
|
"measurement_state": measurement_state,
|
||||||
|
"is_measuring": is_measuring
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/control/{unit_id}/{action}")
|
||||||
|
async def control_slm(unit_id: str, action: str):
|
||||||
|
"""
|
||||||
|
Send control commands to SLM (start, stop, pause, resume, reset).
|
||||||
|
Proxies to SLMM backend.
|
||||||
|
"""
|
||||||
|
valid_actions = ["start", "stop", "pause", "resume", "reset"]
|
||||||
|
|
||||||
|
if action not in valid_actions:
|
||||||
|
return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"detail": f"SLMM returned status {response.status_code}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to control {unit_id}: {e}")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"detail": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/config/{unit_id}", response_class=HTMLResponse)
|
||||||
|
async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get configuration form for a specific SLM unit.
|
||||||
|
Returns HTML partial with configuration form.
|
||||||
|
"""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first()
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
return HTMLResponse(
|
||||||
|
content=f'<div class="text-red-500">Unit {unit_id} not found</div>',
|
||||||
|
status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/slm_config_form.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit": unit
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/{unit_id}")
|
||||||
|
async def save_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Save SLM configuration.
|
||||||
|
Updates unit parameters in the database.
|
||||||
|
"""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first()
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
return {"status": "error", "detail": f"Unit {unit_id} not found"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get form data
|
||||||
|
form_data = await request.form()
|
||||||
|
|
||||||
|
# Update SLM-specific fields
|
||||||
|
unit.slm_model = form_data.get("slm_model") or None
|
||||||
|
unit.slm_serial_number = form_data.get("slm_serial_number") or None
|
||||||
|
unit.slm_frequency_weighting = form_data.get("slm_frequency_weighting") or None
|
||||||
|
unit.slm_time_weighting = form_data.get("slm_time_weighting") or None
|
||||||
|
unit.slm_measurement_range = form_data.get("slm_measurement_range") or None
|
||||||
|
|
||||||
|
# Update network configuration
|
||||||
|
modem_id = form_data.get("deployed_with_modem_id")
|
||||||
|
unit.deployed_with_modem_id = modem_id if modem_id else None
|
||||||
|
|
||||||
|
# Always update TCP and FTP ports (used regardless of modem assignment)
|
||||||
|
unit.slm_tcp_port = int(form_data.get("slm_tcp_port")) if form_data.get("slm_tcp_port") else None
|
||||||
|
unit.slm_ftp_port = int(form_data.get("slm_ftp_port")) if form_data.get("slm_ftp_port") else None
|
||||||
|
|
||||||
|
# Only update direct IP if no modem is assigned
|
||||||
|
if not modem_id:
|
||||||
|
unit.slm_host = form_data.get("slm_host") or None
|
||||||
|
else:
|
||||||
|
# Clear legacy direct IP field when modem is assigned
|
||||||
|
unit.slm_host = None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Updated configuration for SLM {unit_id}")
|
||||||
|
|
||||||
|
# Sync updated configuration to SLMM cache
|
||||||
|
logger.info(f"Syncing SLM {unit_id} config changes to SLMM cache...")
|
||||||
|
result = await sync_slm_to_slmm_cache(
|
||||||
|
unit_id=unit_id,
|
||||||
|
host=unit.slm_host, # Use the updated host from Terra-View
|
||||||
|
tcp_port=unit.slm_tcp_port,
|
||||||
|
ftp_port=unit.slm_ftp_port,
|
||||||
|
deployed_with_modem_id=unit.deployed_with_modem_id, # Resolve modem IP if assigned
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning(f"SLMM cache sync warning for {unit_id}: {result['message']}")
|
||||||
|
# Config still saved in Terra-View (source of truth)
|
||||||
|
|
||||||
|
return {"status": "success", "unit_id": unit_id}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to save config for {unit_id}: {e}")
|
||||||
|
return {"status": "error", "detail": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/test-modem/{modem_id}")
|
||||||
|
async def test_modem_connection(modem_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Test modem connectivity with a simple ping/health check.
|
||||||
|
Returns response time and connection status.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Get modem from database
|
||||||
|
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||||
|
|
||||||
|
if not modem:
|
||||||
|
return {"status": "error", "detail": f"Modem {modem_id} not found"}
|
||||||
|
|
||||||
|
if not modem.ip_address:
|
||||||
|
return {"status": "error", "detail": f"Modem {modem_id} has no IP address configured"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ping the modem (1 packet, 2 second timeout)
|
||||||
|
start_time = time.time()
|
||||||
|
result = subprocess.run(
|
||||||
|
["ping", "-c", "1", "-W", "2", modem.ip_address],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=3
|
||||||
|
)
|
||||||
|
response_time = int((time.time() - start_time) * 1000) # Convert to milliseconds
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"modem_id": modem_id,
|
||||||
|
"ip_address": modem.ip_address,
|
||||||
|
"response_time": response_time,
|
||||||
|
"message": "Modem is responding to ping"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"modem_id": modem_id,
|
||||||
|
"ip_address": modem.ip_address,
|
||||||
|
"detail": "Modem not responding to ping"
|
||||||
|
}
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"modem_id": modem_id,
|
||||||
|
"ip_address": modem.ip_address,
|
||||||
|
"detail": "Ping timeout (> 2 seconds)"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to ping modem {modem_id}: {e}")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"modem_id": modem_id,
|
||||||
|
"detail": str(e)
|
||||||
|
}
|
||||||
301
app/slm/routers/nl43_proxy.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""
|
||||||
|
SLMM (Sound Level Meter Manager) Proxy Router
|
||||||
|
|
||||||
|
Proxies requests from SFM to the standalone SLMM backend service.
|
||||||
|
SLMM runs on port 8100 and handles NL43/NL53 sound level meter communication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import httpx
|
||||||
|
import websockets
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/slmm", tags=["slmm"])
|
||||||
|
|
||||||
|
# SLMM backend URL - configurable via environment variable
|
||||||
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
# WebSocket URL derived from HTTP URL
|
||||||
|
SLMM_WS_BASE_URL = SLMM_BASE_URL.replace("http://", "ws://").replace("https://", "wss://")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def check_slmm_health():
|
||||||
|
"""
|
||||||
|
Check if the SLMM backend service is reachable and healthy.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
response = await client.get(f"{SLMM_BASE_URL}/health")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"slmm_status": "connected",
|
||||||
|
"slmm_url": SLMM_BASE_URL,
|
||||||
|
"slmm_version": data.get("version", "unknown"),
|
||||||
|
"slmm_response": data
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": "degraded",
|
||||||
|
"slmm_status": "error",
|
||||||
|
"slmm_url": SLMM_BASE_URL,
|
||||||
|
"detail": f"SLMM returned status {response.status_code}"
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"slmm_status": "unreachable",
|
||||||
|
"slmm_url": SLMM_BASE_URL,
|
||||||
|
"detail": "Cannot connect to SLMM backend. Is it running?"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"slmm_status": "error",
|
||||||
|
"slmm_url": SLMM_BASE_URL,
|
||||||
|
"detail": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# WebSocket routes MUST come before the catch-all route
|
||||||
|
@router.websocket("/{unit_id}/stream")
|
||||||
|
async def proxy_websocket_stream(websocket: WebSocket, unit_id: str):
|
||||||
|
"""
|
||||||
|
Proxy WebSocket connections to SLMM's /stream endpoint.
|
||||||
|
|
||||||
|
This allows real-time streaming of measurement data from NL43 devices
|
||||||
|
through the SFM unified interface.
|
||||||
|
"""
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"WebSocket connection accepted for SLMM unit {unit_id}")
|
||||||
|
|
||||||
|
# Build target WebSocket URL
|
||||||
|
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/stream"
|
||||||
|
logger.info(f"Connecting to SLMM WebSocket: {target_ws_url}")
|
||||||
|
|
||||||
|
backend_ws = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Connect to SLMM backend WebSocket
|
||||||
|
backend_ws = await websockets.connect(target_ws_url)
|
||||||
|
logger.info(f"Connected to SLMM backend WebSocket for {unit_id}")
|
||||||
|
|
||||||
|
# Create tasks for bidirectional communication
|
||||||
|
async def forward_to_backend():
|
||||||
|
"""Forward messages from client to SLMM backend"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
await backend_ws.send(data)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info(f"Client WebSocket disconnected for {unit_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error forwarding to backend: {e}")
|
||||||
|
|
||||||
|
async def forward_to_client():
|
||||||
|
"""Forward messages from SLMM backend to client"""
|
||||||
|
try:
|
||||||
|
async for message in backend_ws:
|
||||||
|
await websocket.send_text(message)
|
||||||
|
except websockets.exceptions.ConnectionClosed:
|
||||||
|
logger.info(f"Backend WebSocket closed for {unit_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error forwarding to client: {e}")
|
||||||
|
|
||||||
|
# Run both forwarding tasks concurrently
|
||||||
|
await asyncio.gather(
|
||||||
|
forward_to_backend(),
|
||||||
|
forward_to_client(),
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
except websockets.exceptions.WebSocketException as e:
|
||||||
|
logger.error(f"WebSocket error connecting to SLMM backend: {e}")
|
||||||
|
try:
|
||||||
|
await websocket.send_json({
|
||||||
|
"error": "Failed to connect to SLMM backend",
|
||||||
|
"detail": str(e)
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in WebSocket proxy for {unit_id}: {e}")
|
||||||
|
try:
|
||||||
|
await websocket.send_json({
|
||||||
|
"error": "Internal server error",
|
||||||
|
"detail": str(e)
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
# Clean up connections
|
||||||
|
if backend_ws:
|
||||||
|
try:
|
||||||
|
await backend_ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await websocket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info(f"WebSocket proxy closed for {unit_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/{unit_id}/live")
|
||||||
|
async def proxy_websocket_live(websocket: WebSocket, unit_id: str):
|
||||||
|
"""
|
||||||
|
Proxy WebSocket connections to SLMM's /live endpoint.
|
||||||
|
|
||||||
|
Alternative WebSocket endpoint that may be used by some frontend components.
|
||||||
|
"""
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"WebSocket connection accepted for SLMM unit {unit_id} (live endpoint)")
|
||||||
|
|
||||||
|
# Build target WebSocket URL - try /stream endpoint as SLMM uses that for WebSocket
|
||||||
|
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/stream"
|
||||||
|
logger.info(f"Connecting to SLMM WebSocket: {target_ws_url}")
|
||||||
|
|
||||||
|
backend_ws = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Connect to SLMM backend WebSocket
|
||||||
|
backend_ws = await websockets.connect(target_ws_url)
|
||||||
|
logger.info(f"Connected to SLMM backend WebSocket for {unit_id} (live endpoint)")
|
||||||
|
|
||||||
|
# Create tasks for bidirectional communication
|
||||||
|
async def forward_to_backend():
|
||||||
|
"""Forward messages from client to SLMM backend"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
await backend_ws.send(data)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info(f"Client WebSocket disconnected for {unit_id} (live)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error forwarding to backend (live): {e}")
|
||||||
|
|
||||||
|
async def forward_to_client():
|
||||||
|
"""Forward messages from SLMM backend to client"""
|
||||||
|
try:
|
||||||
|
async for message in backend_ws:
|
||||||
|
await websocket.send_text(message)
|
||||||
|
except websockets.exceptions.ConnectionClosed:
|
||||||
|
logger.info(f"Backend WebSocket closed for {unit_id} (live)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error forwarding to client (live): {e}")
|
||||||
|
|
||||||
|
# Run both forwarding tasks concurrently
|
||||||
|
await asyncio.gather(
|
||||||
|
forward_to_backend(),
|
||||||
|
forward_to_client(),
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
except websockets.exceptions.WebSocketException as e:
|
||||||
|
logger.error(f"WebSocket error connecting to SLMM backend (live): {e}")
|
||||||
|
try:
|
||||||
|
await websocket.send_json({
|
||||||
|
"error": "Failed to connect to SLMM backend",
|
||||||
|
"detail": str(e)
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in WebSocket proxy for {unit_id} (live): {e}")
|
||||||
|
try:
|
||||||
|
await websocket.send_json({
|
||||||
|
"error": "Internal server error",
|
||||||
|
"detail": str(e)
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
# Clean up connections
|
||||||
|
if backend_ws:
|
||||||
|
try:
|
||||||
|
await backend_ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await websocket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info(f"WebSocket proxy closed for {unit_id} (live)")
|
||||||
|
|
||||||
|
|
||||||
|
# HTTP catch-all route MUST come after specific routes (including WebSocket routes)
|
||||||
|
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
|
async def proxy_to_slmm(path: str, request: Request):
|
||||||
|
"""
|
||||||
|
Proxy all requests to the SLMM backend service.
|
||||||
|
|
||||||
|
This allows SFM to act as a unified frontend for all device types,
|
||||||
|
while SLMM remains a standalone backend service.
|
||||||
|
"""
|
||||||
|
# Build target URL
|
||||||
|
target_url = f"{SLMM_BASE_URL}/api/nl43/{path}"
|
||||||
|
|
||||||
|
# Get query parameters
|
||||||
|
query_params = dict(request.query_params)
|
||||||
|
|
||||||
|
# Get request body if present
|
||||||
|
body = None
|
||||||
|
if request.method in ["POST", "PUT", "PATCH"]:
|
||||||
|
try:
|
||||||
|
body = await request.body()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read request body: {e}")
|
||||||
|
body = None
|
||||||
|
|
||||||
|
# Get headers (exclude host and other proxy-specific headers)
|
||||||
|
headers = dict(request.headers)
|
||||||
|
headers_to_exclude = ["host", "content-length", "transfer-encoding", "connection"]
|
||||||
|
proxy_headers = {k: v for k, v in headers.items() if k.lower() not in headers_to_exclude}
|
||||||
|
|
||||||
|
logger.info(f"Proxying {request.method} request to SLMM: {target_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
# Forward the request to SLMM
|
||||||
|
response = await client.request(
|
||||||
|
method=request.method,
|
||||||
|
url=target_url,
|
||||||
|
params=query_params,
|
||||||
|
headers=proxy_headers,
|
||||||
|
content=body
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return the response from SLMM
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=dict(response.headers),
|
||||||
|
media_type=response.headers.get("content-type")
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
logger.error(f"Failed to connect to SLMM backend at {SLMM_BASE_URL}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=f"SLMM backend service unavailable. Is SLMM running on {SLMM_BASE_URL}?"
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.error(f"Timeout connecting to SLMM backend at {SLMM_BASE_URL}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=504,
|
||||||
|
detail="SLMM backend timeout"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error proxying to SLMM: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to proxy request to SLMM: {str(e)}"
|
||||||
|
)
|
||||||
123
app/slm/routers/ui.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Sound Level Meter UI Router
|
||||||
|
|
||||||
|
Provides endpoints for SLM dashboard cards, detail pages, and real-time data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/slm", tags=["slm-ui"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://172.19.0.1:8100")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{unit_id}", response_class=HTMLResponse)
|
||||||
|
async def slm_detail_page(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Sound level meter detail page with controls."""
|
||||||
|
|
||||||
|
# Get roster unit
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit or unit.device_type != "sound_level_meter":
|
||||||
|
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||||
|
|
||||||
|
return templates.TemplateResponse("slm_detail.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit": unit,
|
||||||
|
"unit_id": unit_id
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/{unit_id}/summary")
|
||||||
|
async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Get SLM summary data for dashboard card."""
|
||||||
|
|
||||||
|
# Get roster unit
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit or unit.device_type != "sound_level_meter":
|
||||||
|
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||||
|
|
||||||
|
# Try to get live status from SLMM
|
||||||
|
status_data = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
|
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
||||||
|
if response.status_code == 200:
|
||||||
|
status_data = response.json().get("data")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"device_type": "sound_level_meter",
|
||||||
|
"deployed": unit.deployed,
|
||||||
|
"model": unit.slm_model or "NL-43",
|
||||||
|
"location": unit.address or unit.location,
|
||||||
|
"coordinates": unit.coordinates,
|
||||||
|
"note": unit.note,
|
||||||
|
"status": status_data,
|
||||||
|
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/partials/{unit_id}/card", response_class=HTMLResponse)
|
||||||
|
async def slm_dashboard_card(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Render SLM dashboard card partial."""
|
||||||
|
|
||||||
|
summary = await get_slm_summary(unit_id, db)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/slm_card.html", {
|
||||||
|
"request": request,
|
||||||
|
"slm": summary
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/partials/{unit_id}/controls", response_class=HTMLResponse)
|
||||||
|
async def slm_controls_partial(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Render SLM control panel partial."""
|
||||||
|
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit or unit.device_type != "sound_level_meter":
|
||||||
|
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||||
|
|
||||||
|
# Get current status from SLMM
|
||||||
|
measurement_state = None
|
||||||
|
battery_level = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
|
# Get measurement state
|
||||||
|
state_response = await client.get(
|
||||||
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||||
|
)
|
||||||
|
if state_response.status_code == 200:
|
||||||
|
measurement_state = state_response.json().get("measurement_state")
|
||||||
|
|
||||||
|
# Get battery level
|
||||||
|
battery_response = await client.get(
|
||||||
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/battery"
|
||||||
|
)
|
||||||
|
if battery_response.status_code == 200:
|
||||||
|
battery_level = battery_response.json().get("battery_level")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get SLM control data for {unit_id}: {e}")
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/slm_controls.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"unit": unit,
|
||||||
|
"measurement_state": measurement_state,
|
||||||
|
"battery_level": battery_level,
|
||||||
|
"is_measuring": measurement_state == "Start"
|
||||||
|
})
|
||||||
0
app/slm/services/__init__.py
Normal file
0
app/ui/__init__.py
Normal file
92
app/ui/routes.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
UI Layer Routes - HTML page routes only (no business logic)
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
import os
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Setup Jinja2 templates
|
||||||
|
templates = Jinja2Templates(directory="app/ui/templates")
|
||||||
|
|
||||||
|
# Read environment (development or production)
|
||||||
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
VERSION = "1.0.0" # Terra-View version
|
||||||
|
|
||||||
|
# Override TemplateResponse to include environment and version in context
|
||||||
|
original_template_response = templates.TemplateResponse
|
||||||
|
def custom_template_response(name, context=None, *args, **kwargs):
|
||||||
|
if context is None:
|
||||||
|
context = {}
|
||||||
|
context["environment"] = ENVIRONMENT
|
||||||
|
context["version"] = VERSION
|
||||||
|
return original_template_response(name, context, *args, **kwargs)
|
||||||
|
templates.TemplateResponse = custom_template_response
|
||||||
|
|
||||||
|
|
||||||
|
# ===== HTML PAGE ROUTES =====
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request):
|
||||||
|
"""Dashboard home page"""
|
||||||
|
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roster", response_class=HTMLResponse)
|
||||||
|
async def roster_page(request: Request):
|
||||||
|
"""Fleet roster page"""
|
||||||
|
return templates.TemplateResponse("roster.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}", response_class=HTMLResponse)
|
||||||
|
async def unit_detail_page(request: Request, unit_id: str):
|
||||||
|
"""Unit detail page"""
|
||||||
|
return templates.TemplateResponse("unit_detail.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit_id": unit_id
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_page(request: Request):
|
||||||
|
"""Settings page for roster management"""
|
||||||
|
return templates.TemplateResponse("settings.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sound-level-meters", response_class=HTMLResponse)
|
||||||
|
async def sound_level_meters_page(request: Request):
|
||||||
|
"""Sound Level Meters management dashboard"""
|
||||||
|
return templates.TemplateResponse("sound_level_meters.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/seismographs", response_class=HTMLResponse)
|
||||||
|
async def seismographs_page(request: Request):
|
||||||
|
"""Seismographs management dashboard"""
|
||||||
|
return templates.TemplateResponse("seismographs.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
# ===== PWA ROUTES =====
|
||||||
|
|
||||||
|
@router.get("/sw.js")
|
||||||
|
async def service_worker():
|
||||||
|
"""Serve service worker with proper headers for PWA"""
|
||||||
|
return FileResponse(
|
||||||
|
"app/ui/static/sw.js",
|
||||||
|
media_type="application/javascript",
|
||||||
|
headers={
|
||||||
|
"Service-Worker-Allowed": "/",
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/offline-db.js")
|
||||||
|
async def offline_db_script():
|
||||||
|
"""Serve offline database script"""
|
||||||
|
return FileResponse(
|
||||||
|
"app/ui/static/offline-db.js",
|
||||||
|
media_type="application/javascript",
|
||||||
|
headers={"Cache-Control": "no-cache"}
|
||||||
|
)
|
||||||
78
app/ui/static/icons/ICON_GENERATION_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# PWA Icon Generation Instructions
|
||||||
|
|
||||||
|
The PWA manifest requires 8 icon sizes for full compatibility across devices.
|
||||||
|
|
||||||
|
## Required Icon Sizes
|
||||||
|
|
||||||
|
- 72x72px
|
||||||
|
- 96x96px
|
||||||
|
- 128x128px
|
||||||
|
- 144x144px
|
||||||
|
- 152x152px
|
||||||
|
- 192x192px
|
||||||
|
- 384x384px
|
||||||
|
- 512x512px (maskable)
|
||||||
|
|
||||||
|
## Design Guidelines
|
||||||
|
|
||||||
|
**Background:** Navy blue (#142a66)
|
||||||
|
**Icon/Logo:** Orange (#f48b1c)
|
||||||
|
**Style:** Simple, recognizable design that works at small sizes
|
||||||
|
|
||||||
|
## Quick Generation Methods
|
||||||
|
|
||||||
|
### Option 1: Online PWA Icon Generator
|
||||||
|
|
||||||
|
1. Visit: https://www.pwabuilder.com/imageGenerator
|
||||||
|
2. Upload a 512x512px source image
|
||||||
|
3. Download the generated icon pack
|
||||||
|
4. Copy PNG files to this directory
|
||||||
|
|
||||||
|
### Option 2: ImageMagick (Command Line)
|
||||||
|
|
||||||
|
If you have a 512x512px source image called `source-icon.png`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the icons directory
|
||||||
|
for size in 72 96 128 144 152 192 384 512; do
|
||||||
|
convert source-icon.png -resize ${size}x${size} icon-${size}.png
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Photoshop/GIMP
|
||||||
|
|
||||||
|
1. Create a 512x512px canvas
|
||||||
|
2. Add your design (navy background + orange icon)
|
||||||
|
3. Save/Export for each required size
|
||||||
|
4. Name files as: icon-72.png, icon-96.png, etc.
|
||||||
|
|
||||||
|
## Temporary Placeholder
|
||||||
|
|
||||||
|
For testing, you can use a simple colored square:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate simple colored placeholder icons
|
||||||
|
for size in 72 96 128 144 152 192 384 512; do
|
||||||
|
convert -size ${size}x${size} xc:#142a66 \
|
||||||
|
-gravity center \
|
||||||
|
-fill '#f48b1c' \
|
||||||
|
-pointsize $((size / 2)) \
|
||||||
|
-annotate +0+0 'SFM' \
|
||||||
|
icon-${size}.png
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After generating icons, verify:
|
||||||
|
- All 8 sizes exist in this directory
|
||||||
|
- Files are named exactly: icon-72.png, icon-96.png, etc.
|
||||||
|
- Images have transparent or navy background
|
||||||
|
- Logo/text is clearly visible at smallest size (72px)
|
||||||
|
|
||||||
|
## Testing PWA Installation
|
||||||
|
|
||||||
|
1. Open SFM in Chrome on Android or Safari on iOS
|
||||||
|
2. Look for "Install App" or "Add to Home Screen" prompt
|
||||||
|
3. Check that the correct icon appears in the install dialog
|
||||||
|
4. After installation, verify icon on home screen
|
||||||
BIN
app/ui/static/icons/icon-128.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
4
app/ui/static/icons/icon-128.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="128" height="128" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="128" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-144.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
4
app/ui/static/icons/icon-144.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="144" height="144" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 287 B |
BIN
app/ui/static/icons/icon-152.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
4
app/ui/static/icons/icon-152.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="152" height="152" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="152" height="152" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="152" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
4
app/ui/static/icons/icon-192.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="192" height="192" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="192" height="192" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="192" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-384.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
4
app/ui/static/icons/icon-384.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="384" height="384" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="384" height="384" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="38" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 287 B |
BIN
app/ui/static/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
4
app/ui/static/icons/icon-512.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="512" height="512" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="512" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-72.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
4
app/ui/static/icons/icon-72.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="72" height="72" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="72" height="72" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="72" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
BIN
app/ui/static/icons/icon-96.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
4
app/ui/static/icons/icon-96.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="96" height="96" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="96" height="96" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="96" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
78
app/ui/static/manifest.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"name": "Seismo Fleet Manager",
|
||||||
|
"short_name": "SFM",
|
||||||
|
"description": "Real-time seismograph and modem fleet monitoring and management",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#142a66",
|
||||||
|
"theme_color": "#f48b1c",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/static/screenshots/dashboard.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "540x720",
|
||||||
|
"form_factor": "narrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["utilities", "productivity"],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Dashboard",
|
||||||
|
"short_name": "Dashboard",
|
||||||
|
"description": "View fleet status dashboard",
|
||||||
|
"url": "/",
|
||||||
|
"icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fleet Roster",
|
||||||
|
"short_name": "Roster",
|
||||||
|
"description": "View and manage fleet roster",
|
||||||
|
"url": "/roster",
|
||||||
|
"icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
612
app/ui/static/mobile.css
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
/* Mobile-specific styles for Seismo Fleet Manager */
|
||||||
|
/* Touch-optimized, portrait-first design */
|
||||||
|
|
||||||
|
/* ===== MOBILE TOUCH TARGETS ===== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
/* Buttons - 44x44px minimum (iOS standard) */
|
||||||
|
.btn, button:not(.tab-button), .button, a.button {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon-only buttons */
|
||||||
|
.icon-button, .btn-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form inputs - 48px height, 16px font prevents iOS zoom */
|
||||||
|
input:not([type="checkbox"]):not([type="radio"]),
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
min-height: 48px;
|
||||||
|
font-size: 16px !important;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkboxes and radio buttons - larger touch targets */
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom nav buttons - 56px industry standard */
|
||||||
|
.bottom-nav button {
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Increase spacing between clickable elements */
|
||||||
|
.btn + .btn,
|
||||||
|
button + button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== HAMBURGER MENU ===== */
|
||||||
|
.hamburger-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
z-index: 50;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hamburger-btn {
|
||||||
|
background-color: #1e293b;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger icon */
|
||||||
|
.hamburger-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-line {
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background-color: #374151;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hamburger-line {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger animation when menu open */
|
||||||
|
.menu-open .hamburger-line:nth-child(1) {
|
||||||
|
transform: translateY(8px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-open .hamburger-line:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-open .hamburger-line:nth-child(3) {
|
||||||
|
transform: translateY(-8px) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SIDEBAR (RESPONSIVE) ===== */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 16rem; /* 256px */
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 40;
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(0) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== BACKDROP ===== */
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 30;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop.show {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== BOTTOM NAVIGATION ===== */
|
||||||
|
.bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4rem;
|
||||||
|
background-color: white;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
z-index: 20;
|
||||||
|
box-shadow: 0 -1px 3px 0 rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bottom-nav {
|
||||||
|
background-color: #1e293b;
|
||||||
|
border-top-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #6b7280;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bottom-nav-btn:active {
|
||||||
|
background-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn.active {
|
||||||
|
color: #f48b1c; /* seismo-orange */
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn span {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.bottom-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MAIN CONTENT ADJUSTMENTS ===== */
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-bottom: 5rem; /* 80px for bottom nav */
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
margin-left: 16rem; /* 256px sidebar width */
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE ROSTER CARDS ===== */
|
||||||
|
.unit-card {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .unit-card {
|
||||||
|
background-color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-card:hover {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== UNIT DETAIL MODAL (BOTTOM SHEET) ===== */
|
||||||
|
.unit-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal.show {
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 85vh;
|
||||||
|
background-color: white;
|
||||||
|
border-top-left-radius: 1rem;
|
||||||
|
border-top-right-radius: 1rem;
|
||||||
|
box-shadow: 0 -4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
overflow-y: auto;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal.show .unit-modal-content {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .unit-modal-content {
|
||||||
|
background-color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.unit-modal {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal-content {
|
||||||
|
max-width: 42rem; /* 672px */
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal.show .unit-modal-content {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal handle bar (mobile only) */
|
||||||
|
.modal-handle {
|
||||||
|
height: 4px;
|
||||||
|
width: 3rem;
|
||||||
|
background-color: #d1d5db;
|
||||||
|
border-radius: 9999px;
|
||||||
|
margin: 0.75rem auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.modal-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== OFFLINE INDICATOR ===== */
|
||||||
|
.offline-indicator {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: #eab308; /* yellow-500 */
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 50;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-indicator.show {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SYNC TOAST ===== */
|
||||||
|
.sync-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 6rem; /* Above bottom nav */
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background-color: #22c55e; /* green-500 */
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
z-index: 50;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
transition: opacity 0.3s, transform 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sync-toast {
|
||||||
|
bottom: 1rem;
|
||||||
|
left: auto;
|
||||||
|
right: 1rem;
|
||||||
|
max-width: 20rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE SEARCH BAR (STICKY) ===== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-search-sticky {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
margin: -1rem -1rem 1rem -1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .mobile-search-sticky {
|
||||||
|
background-color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.mobile-search-sticky {
|
||||||
|
position: static;
|
||||||
|
background-color: transparent;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== STATUS BADGES ===== */
|
||||||
|
.status-dot {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== DEVICE TYPE BADGES ===== */
|
||||||
|
.device-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE MAP HEIGHT ===== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
#fleet-map {
|
||||||
|
height: 16rem !important; /* 256px on mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
#unit-map {
|
||||||
|
height: 16rem !important; /* 256px on mobile */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MAP OVERLAP FIX ===== */
|
||||||
|
/* Prevent map and controls from overlapping UI elements on mobile */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
/* Constrain leaflet container to prevent overflow */
|
||||||
|
.leaflet-container {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Leaflet's default high z-index values */
|
||||||
|
/* Bottom nav is z-20, sidebar is z-40, so map must be below */
|
||||||
|
.leaflet-pane,
|
||||||
|
.leaflet-tile-pane,
|
||||||
|
.leaflet-overlay-pane,
|
||||||
|
.leaflet-shadow-pane,
|
||||||
|
.leaflet-marker-pane,
|
||||||
|
.leaflet-tooltip-pane,
|
||||||
|
.leaflet-popup-pane {
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map controls should also be below navigation elements */
|
||||||
|
.leaflet-control-container,
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom,
|
||||||
|
.leaflet-left,
|
||||||
|
.leaflet-right {
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control {
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When sidebar is open, hide all Leaflet controls (zoom, attribution, etc) */
|
||||||
|
body.menu-open .leaflet-control-container {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure map tiles are non-interactive when sidebar is open */
|
||||||
|
body.menu-open #fleet-map,
|
||||||
|
body.menu-open #unit-map {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== PENDING SYNC BADGE ===== */
|
||||||
|
.pending-sync-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background-color: #fef3c7; /* amber-100 */
|
||||||
|
color: #92400e; /* amber-800 */
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .pending-sync-badge {
|
||||||
|
background-color: #78350f;
|
||||||
|
color: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-sync-badge::before {
|
||||||
|
content: "⏳";
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE-SPECIFIC UTILITY CLASSES ===== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-text-lg {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-text-xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-p-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-mb-4 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== ACCESSIBILITY ===== */
|
||||||
|
/* Improve focus visibility on mobile */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
button:focus-visible,
|
||||||
|
a:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 2px solid #f48b1c;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent text selection on buttons (better mobile UX) */
|
||||||
|
button,
|
||||||
|
.btn,
|
||||||
|
.button {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SMOOTH SCROLLING ===== */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent overscroll bounce on iOS */
|
||||||
|
body {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== LOADING STATES ===== */
|
||||||
|
.loading-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SAFE AREA SUPPORT (iOS notch) ===== */
|
||||||
|
@supports (padding: env(safe-area-inset-bottom)) {
|
||||||
|
.bottom-nav {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
height: calc(4rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding-bottom: calc(5rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
597
app/ui/static/mobile.js
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
/* Mobile JavaScript for Seismo Fleet Manager */
|
||||||
|
/* Handles hamburger menu, modals, offline sync, and mobile interactions */
|
||||||
|
|
||||||
|
// ===== GLOBAL STATE =====
|
||||||
|
let currentUnitData = null;
|
||||||
|
let isOnline = navigator.onLine;
|
||||||
|
|
||||||
|
// ===== HAMBURGER MENU TOGGLE =====
|
||||||
|
function toggleMenu() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const backdrop = document.getElementById('backdrop');
|
||||||
|
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
||||||
|
|
||||||
|
if (sidebar && backdrop) {
|
||||||
|
const isOpen = sidebar.classList.contains('open');
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
// Close menu
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
backdrop.classList.remove('show');
|
||||||
|
hamburgerBtn?.classList.remove('menu-open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
document.body.classList.remove('menu-open');
|
||||||
|
} else {
|
||||||
|
// Open menu
|
||||||
|
sidebar.classList.add('open');
|
||||||
|
backdrop.classList.add('show');
|
||||||
|
hamburgerBtn?.classList.add('menu-open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
document.body.classList.add('menu-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menu when clicking backdrop
|
||||||
|
function closeMenuFromBackdrop() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const backdrop = document.getElementById('backdrop');
|
||||||
|
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
||||||
|
|
||||||
|
if (sidebar && backdrop) {
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
backdrop.classList.remove('show');
|
||||||
|
hamburgerBtn?.classList.remove('menu-open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
document.body.classList.remove('menu-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menu when window is resized to desktop
|
||||||
|
function handleResize() {
|
||||||
|
if (window.innerWidth >= 768) {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const backdrop = document.getElementById('backdrop');
|
||||||
|
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
||||||
|
|
||||||
|
if (sidebar && backdrop) {
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
backdrop.classList.remove('show');
|
||||||
|
hamburgerBtn?.classList.remove('menu-open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
document.body.classList.remove('menu-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UNIT DETAIL MODAL =====
|
||||||
|
function openUnitModal(unitId, status = null, age = null) {
|
||||||
|
const modal = document.getElementById('unitModal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
// Store the status info passed from the card
|
||||||
|
// Accept status if it's a non-empty string, use age if provided or default to '--'
|
||||||
|
const cardStatusInfo = (status && status !== '') ? {
|
||||||
|
status: status,
|
||||||
|
age: age || '--'
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
console.log('openUnitModal:', { unitId, status, age, cardStatusInfo });
|
||||||
|
|
||||||
|
// Fetch unit data and populate modal
|
||||||
|
fetchUnitDetails(unitId).then(unit => {
|
||||||
|
if (unit) {
|
||||||
|
currentUnitData = unit;
|
||||||
|
// Pass the card status info to the populate function
|
||||||
|
populateUnitModal(unit, cardStatusInfo);
|
||||||
|
modal.classList.add('show');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUnitModal(event) {
|
||||||
|
// Only close if clicking backdrop or close button
|
||||||
|
if (event && event.target.closest('.unit-modal-content') && !event.target.closest('[data-close-modal]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('unitModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
currentUnitData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUnitDetails(unitId) {
|
||||||
|
try {
|
||||||
|
// Try to fetch from network first
|
||||||
|
const response = await fetch(`/api/roster/${unitId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const unit = await response.json();
|
||||||
|
|
||||||
|
// Save to IndexedDB if offline support is available
|
||||||
|
if (window.offlineDB) {
|
||||||
|
await window.offlineDB.saveUnit(unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return unit;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Network fetch failed, trying offline storage:', error);
|
||||||
|
|
||||||
|
// Fall back to offline storage
|
||||||
|
if (window.offlineDB) {
|
||||||
|
return await window.offlineDB.getUnit(unitId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateUnitModal(unit, cardStatusInfo = null) {
|
||||||
|
// Set unit ID in header
|
||||||
|
const modalUnitId = document.getElementById('modalUnitId');
|
||||||
|
if (modalUnitId) {
|
||||||
|
modalUnitId.textContent = unit.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate modal content
|
||||||
|
const modalContent = document.getElementById('modalContent');
|
||||||
|
if (!modalContent) return;
|
||||||
|
|
||||||
|
// Use status from card if provided, otherwise get from snapshot or derive from unit
|
||||||
|
let statusInfo = cardStatusInfo || getUnitStatus(unit.id, unit);
|
||||||
|
console.log('populateUnitModal:', { unit, cardStatusInfo, statusInfo });
|
||||||
|
|
||||||
|
const statusColor = statusInfo.status === 'OK' ? 'green' :
|
||||||
|
statusInfo.status === 'Pending' ? 'yellow' :
|
||||||
|
statusInfo.status === 'Missing' ? 'red' : 'gray';
|
||||||
|
|
||||||
|
const statusTextColor = statusInfo.status === 'OK' ? 'text-green-600 dark:text-green-400' :
|
||||||
|
statusInfo.status === 'Pending' ? 'text-yellow-600 dark:text-yellow-400' :
|
||||||
|
statusInfo.status === 'Missing' ? 'text-red-600 dark:text-red-400' :
|
||||||
|
'text-gray-600 dark:text-gray-400';
|
||||||
|
|
||||||
|
// Determine status label (show "Benched" instead of "Unknown" for non-deployed units)
|
||||||
|
let statusLabel = statusInfo.status;
|
||||||
|
if ((statusInfo.status === 'Unknown' || statusInfo.status === 'N/A') && !unit.deployed) {
|
||||||
|
statusLabel = 'Benched';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create navigation URL for location
|
||||||
|
const createNavUrl = (address, coordinates) => {
|
||||||
|
if (address) {
|
||||||
|
// Use address for navigation
|
||||||
|
const encodedAddress = encodeURIComponent(address);
|
||||||
|
// Universal link that works on iOS and Android
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodedAddress}`;
|
||||||
|
} else if (coordinates) {
|
||||||
|
// Use coordinates for navigation (format: lat,lon)
|
||||||
|
const encodedCoords = encodeURIComponent(coordinates);
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodedCoords}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navUrl = createNavUrl(unit.address, unit.coordinates);
|
||||||
|
|
||||||
|
modalContent.innerHTML = `
|
||||||
|
<!-- Status Section -->
|
||||||
|
<div class="flex items-center justify-between pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-4 h-4 rounded-full bg-${statusColor}-500"></span>
|
||||||
|
<span class="font-semibold ${statusTextColor}">${statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-500">${statusInfo.age || '--'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Info -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Device Type</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.device_type || '--'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${unit.unit_type ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Unit Type</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.unit_type}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.project_id ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Project ID</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.project_id}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.address ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Address</label>
|
||||||
|
${navUrl ? `
|
||||||
|
<a href="${navUrl}" target="_blank" class="mt-1 flex items-center gap-2 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
|
||||||
|
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="underline">${unit.address}</span>
|
||||||
|
</a>
|
||||||
|
` : `
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.address}</p>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.coordinates && !unit.address ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Coordinates</label>
|
||||||
|
${navUrl ? `
|
||||||
|
<a href="${navUrl}" target="_blank" class="mt-1 flex items-center gap-2 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
|
||||||
|
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="font-mono text-sm underline">${unit.coordinates}</span>
|
||||||
|
</a>
|
||||||
|
` : `
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white font-mono text-sm">${unit.coordinates}</p>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Seismograph-specific fields -->
|
||||||
|
${unit.device_type === 'seismograph' ? `
|
||||||
|
${unit.last_calibrated ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Last Calibrated</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.last_calibrated}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.next_calibration_due ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Next Calibration Due</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.next_calibration_due}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.deployed_with_modem_id ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.deployed_with_modem_id}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Modem-specific fields -->
|
||||||
|
${unit.device_type === 'modem' ? `
|
||||||
|
${unit.ip_address ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">IP Address</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.ip_address}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.phone_number ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Phone Number</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.phone_number}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.hardware_model ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Hardware Model</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.hardware_model}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.note ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">${unit.note}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Deployed</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.deployed ? 'Yes' : 'No'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Retired</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.retired ? 'Yes' : 'No'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Update action buttons
|
||||||
|
const editBtn = document.getElementById('modalEditBtn');
|
||||||
|
const deployBtn = document.getElementById('modalDeployBtn');
|
||||||
|
const deleteBtn = document.getElementById('modalDeleteBtn');
|
||||||
|
|
||||||
|
if (editBtn) {
|
||||||
|
editBtn.onclick = () => {
|
||||||
|
window.location.href = `/unit/${unit.id}`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deployBtn) {
|
||||||
|
deployBtn.textContent = unit.deployed ? 'Bench Unit' : 'Deploy Unit';
|
||||||
|
deployBtn.onclick = () => toggleDeployStatus(unit.id, !unit.deployed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.onclick = () => deleteUnit(unit.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUnitStatus(unitId, unit = null) {
|
||||||
|
// Prefer roster table data if it was rendered with the current view
|
||||||
|
if (window.rosterStatusMap && window.rosterStatusMap[unitId]) {
|
||||||
|
return window.rosterStatusMap[unitId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get status from dashboard snapshot if it exists
|
||||||
|
if (window.lastStatusSnapshot && window.lastStatusSnapshot.units && window.lastStatusSnapshot.units[unitId]) {
|
||||||
|
const unitStatus = window.lastStatusSnapshot.units[unitId];
|
||||||
|
return {
|
||||||
|
status: unitStatus.status,
|
||||||
|
age: unitStatus.age,
|
||||||
|
last: unitStatus.last
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if unit data is provided, derive status from deployment state
|
||||||
|
if (unit) {
|
||||||
|
if (unit.deployed) {
|
||||||
|
// For deployed units without status data, default to "Unknown"
|
||||||
|
return { status: 'Unknown', age: '--', last: '--' };
|
||||||
|
} else {
|
||||||
|
// For benched units, use "N/A" which will be displayed as "Benched"
|
||||||
|
return { status: 'N/A', age: '--', last: '--' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'Unknown', age: '--', last: '--' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDeployStatus(unitId, deployed) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('deployed', deployed ? 'true' : 'false');
|
||||||
|
|
||||||
|
const response = await fetch(`/api/roster/edit/${unitId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast('✓ Unit updated successfully');
|
||||||
|
closeUnitModal();
|
||||||
|
|
||||||
|
// Trigger HTMX refresh if on roster page
|
||||||
|
const rosterTable = document.querySelector('[hx-get*="roster"]');
|
||||||
|
if (rosterTable) {
|
||||||
|
htmx.trigger(rosterTable, 'refresh');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('❌ Failed to update unit', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling deploy status:', error);
|
||||||
|
showToast('❌ Failed to update unit', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUnit(unitId) {
|
||||||
|
if (!confirm(`Are you sure you want to delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/roster/${unitId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast('✓ Unit deleted successfully');
|
||||||
|
closeUnitModal();
|
||||||
|
|
||||||
|
// Refresh roster page if present
|
||||||
|
const rosterTable = document.querySelector('[hx-get*="roster"]');
|
||||||
|
if (rosterTable) {
|
||||||
|
htmx.trigger(rosterTable, 'refresh');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('❌ Failed to delete unit', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting unit:', error);
|
||||||
|
showToast('❌ Failed to delete unit', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ONLINE/OFFLINE STATUS =====
|
||||||
|
function updateOnlineStatus() {
|
||||||
|
isOnline = navigator.onLine;
|
||||||
|
const offlineIndicator = document.getElementById('offlineIndicator');
|
||||||
|
|
||||||
|
if (offlineIndicator) {
|
||||||
|
if (isOnline) {
|
||||||
|
offlineIndicator.classList.remove('show');
|
||||||
|
// Trigger sync when coming back online
|
||||||
|
if (window.offlineDB) {
|
||||||
|
syncPendingEdits();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
offlineIndicator.classList.add('show');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('online', updateOnlineStatus);
|
||||||
|
window.addEventListener('offline', updateOnlineStatus);
|
||||||
|
|
||||||
|
// ===== SYNC FUNCTIONALITY =====
|
||||||
|
async function syncPendingEdits() {
|
||||||
|
if (!window.offlineDB) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pendingEdits = await window.offlineDB.getPendingEdits();
|
||||||
|
|
||||||
|
if (pendingEdits.length === 0) return;
|
||||||
|
|
||||||
|
console.log(`Syncing ${pendingEdits.length} pending edits...`);
|
||||||
|
|
||||||
|
for (const edit of pendingEdits) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(edit.changes)) {
|
||||||
|
formData.append(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/roster/edit/${edit.unitId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await window.offlineDB.clearEdit(edit.id);
|
||||||
|
console.log(`Synced edit ${edit.id} for unit ${edit.unitId}`);
|
||||||
|
} else {
|
||||||
|
console.error(`Failed to sync edit ${edit.id}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error syncing edit ${edit.id}:`, error);
|
||||||
|
// Keep in queue for next sync attempt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
showToast('✓ Synced successfully');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in syncPendingEdits:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual sync button
|
||||||
|
function manualSync() {
|
||||||
|
if (!isOnline) {
|
||||||
|
showToast('⚠️ Cannot sync while offline', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPendingEdits();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TOAST NOTIFICATIONS =====
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const toast = document.getElementById('syncToast');
|
||||||
|
if (!toast) return;
|
||||||
|
|
||||||
|
// Update toast appearance based on type
|
||||||
|
toast.classList.remove('bg-green-500', 'bg-red-500', 'bg-yellow-500');
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
toast.classList.add('bg-green-500');
|
||||||
|
} else if (type === 'error') {
|
||||||
|
toast.classList.add('bg-red-500');
|
||||||
|
} else if (type === 'warning') {
|
||||||
|
toast.classList.add('bg-yellow-500');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.classList.add('show');
|
||||||
|
|
||||||
|
// Auto-hide after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== BOTTOM NAV ACTIVE STATE =====
|
||||||
|
function updateBottomNavActiveState() {
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
const navButtons = document.querySelectorAll('.bottom-nav-btn');
|
||||||
|
|
||||||
|
navButtons.forEach(btn => {
|
||||||
|
const href = btn.getAttribute('data-href');
|
||||||
|
if (href && (currentPath === href || (href !== '/' && currentPath.startsWith(href)))) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== INITIALIZATION =====
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Initialize online/offline status
|
||||||
|
updateOnlineStatus();
|
||||||
|
|
||||||
|
// Update bottom nav active state
|
||||||
|
updateBottomNavActiveState();
|
||||||
|
|
||||||
|
// Add resize listener
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Close menu on navigation (for mobile)
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const link = e.target.closest('a');
|
||||||
|
if (link && link.closest('#sidebar')) {
|
||||||
|
// Delay to allow navigation to start
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
closeMenuFromBackdrop();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent scroll when modals are open (iOS fix)
|
||||||
|
document.addEventListener('touchmove', (e) => {
|
||||||
|
const modal = document.querySelector('.unit-modal.show, #sidebar.open');
|
||||||
|
if (modal && !e.target.closest('.unit-modal-content, #sidebar')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
console.log('Mobile.js initialized');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== SERVICE WORKER REGISTRATION =====
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then(registration => {
|
||||||
|
console.log('Service Worker registered:', registration);
|
||||||
|
|
||||||
|
// Check for updates periodically
|
||||||
|
setInterval(() => {
|
||||||
|
registration.update();
|
||||||
|
}, 60 * 60 * 1000); // Check every hour
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Service Worker registration failed:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for service worker updates
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
console.log('Service Worker updated, reloading page...');
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export functions for global use
|
||||||
|
window.toggleMenu = toggleMenu;
|
||||||
|
window.closeMenuFromBackdrop = closeMenuFromBackdrop;
|
||||||
|
window.openUnitModal = openUnitModal;
|
||||||
|
window.closeUnitModal = closeUnitModal;
|
||||||
|
window.manualSync = manualSync;
|
||||||
|
window.showToast = showToast;
|
||||||
349
app/ui/static/offline-db.js
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/* IndexedDB wrapper for offline data storage in SFM */
|
||||||
|
/* Handles unit data, status snapshots, and pending edit queue */
|
||||||
|
|
||||||
|
class OfflineDB {
|
||||||
|
constructor() {
|
||||||
|
this.dbName = 'sfm-offline-db';
|
||||||
|
this.version = 1;
|
||||||
|
this.db = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
async init() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(this.dbName, this.version);
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error('IndexedDB error:', request.error);
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
this.db = request.result;
|
||||||
|
console.log('IndexedDB initialized successfully');
|
||||||
|
resolve(this.db);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
|
||||||
|
// Units store - full unit details
|
||||||
|
if (!db.objectStoreNames.contains('units')) {
|
||||||
|
const unitsStore = db.createObjectStore('units', { keyPath: 'id' });
|
||||||
|
unitsStore.createIndex('device_type', 'device_type', { unique: false });
|
||||||
|
unitsStore.createIndex('deployed', 'deployed', { unique: false });
|
||||||
|
console.log('Created units object store');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status snapshot store - latest status data
|
||||||
|
if (!db.objectStoreNames.contains('status-snapshot')) {
|
||||||
|
db.createObjectStore('status-snapshot', { keyPath: 'timestamp' });
|
||||||
|
console.log('Created status-snapshot object store');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending edits store - offline edit queue
|
||||||
|
if (!db.objectStoreNames.contains('pending-edits')) {
|
||||||
|
const editsStore = db.createObjectStore('pending-edits', {
|
||||||
|
keyPath: 'id',
|
||||||
|
autoIncrement: true
|
||||||
|
});
|
||||||
|
editsStore.createIndex('unitId', 'unitId', { unique: false });
|
||||||
|
editsStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||||
|
console.log('Created pending-edits object store');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UNITS OPERATIONS =====
|
||||||
|
|
||||||
|
// Save or update a unit
|
||||||
|
async saveUnit(unit) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('units');
|
||||||
|
const request = store.put(unit);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a single unit by ID
|
||||||
|
async getUnit(unitId) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readonly');
|
||||||
|
const store = transaction.objectStore('units');
|
||||||
|
const request = store.get(unitId);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all units
|
||||||
|
async getAllUnits() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readonly');
|
||||||
|
const store = transaction.objectStore('units');
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a unit
|
||||||
|
async deleteUnit(unitId) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('units');
|
||||||
|
const request = store.delete(unitId);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== STATUS SNAPSHOT OPERATIONS =====
|
||||||
|
|
||||||
|
// Save status snapshot
|
||||||
|
async saveSnapshot(snapshot) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['status-snapshot'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('status-snapshot');
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
const snapshotWithTimestamp = {
|
||||||
|
...snapshot,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = store.put(snapshotWithTimestamp);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get latest status snapshot
|
||||||
|
async getLatestSnapshot() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['status-snapshot'], 'readonly');
|
||||||
|
const store = transaction.objectStore('status-snapshot');
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const snapshots = request.result;
|
||||||
|
if (snapshots.length > 0) {
|
||||||
|
// Return the most recent snapshot
|
||||||
|
const latest = snapshots.reduce((prev, current) =>
|
||||||
|
(prev.timestamp > current.timestamp) ? prev : current
|
||||||
|
);
|
||||||
|
resolve(latest);
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear old snapshots (keep only latest)
|
||||||
|
async clearOldSnapshots() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['status-snapshot'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('status-snapshot');
|
||||||
|
const getAllRequest = store.getAll();
|
||||||
|
|
||||||
|
getAllRequest.onsuccess = () => {
|
||||||
|
const snapshots = getAllRequest.result;
|
||||||
|
|
||||||
|
if (snapshots.length > 1) {
|
||||||
|
// Sort by timestamp, keep only the latest
|
||||||
|
snapshots.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
|
||||||
|
// Delete all except the first (latest)
|
||||||
|
for (let i = 1; i < snapshots.length; i++) {
|
||||||
|
store.delete(snapshots[i].timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
getAllRequest.onerror = () => reject(getAllRequest.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== PENDING EDITS OPERATIONS =====
|
||||||
|
|
||||||
|
// Queue an edit for offline sync
|
||||||
|
async queueEdit(unitId, changes) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
|
||||||
|
const edit = {
|
||||||
|
unitId,
|
||||||
|
changes,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = store.add(edit);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log(`Queued edit for unit ${unitId}`);
|
||||||
|
resolve(request.result);
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all pending edits
|
||||||
|
async getPendingEdits() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readonly');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending edits count
|
||||||
|
async getPendingEditsCount() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readonly');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.count();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear a synced edit
|
||||||
|
async clearEdit(editId) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.delete(editId);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log(`Cleared edit ${editId} from queue`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all pending edits
|
||||||
|
async clearAllEdits() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.clear();
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log('Cleared all pending edits');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UTILITY OPERATIONS =====
|
||||||
|
|
||||||
|
// Clear all data (for debugging/reset)
|
||||||
|
async clearAllData() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const storeNames = ['units', 'status-snapshot', 'pending-edits'];
|
||||||
|
const transaction = this.db.transaction(storeNames, 'readwrite');
|
||||||
|
|
||||||
|
storeNames.forEach(storeName => {
|
||||||
|
transaction.objectStore(storeName).clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction.oncomplete = () => {
|
||||||
|
console.log('Cleared all offline data');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get database statistics
|
||||||
|
async getStats() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
const unitsCount = await new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readonly');
|
||||||
|
const request = transaction.objectStore('units').count();
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingEditsCount = await this.getPendingEditsCount();
|
||||||
|
|
||||||
|
const hasSnapshot = await new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['status-snapshot'], 'readonly');
|
||||||
|
const request = transaction.objectStore('status-snapshot').count();
|
||||||
|
request.onsuccess = () => resolve(request.result > 0);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
unitsCount,
|
||||||
|
pendingEditsCount,
|
||||||
|
hasSnapshot
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create global instance
|
||||||
|
window.offlineDB = new OfflineDB();
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
try {
|
||||||
|
await window.offlineDB.init();
|
||||||
|
console.log('Offline database ready');
|
||||||
|
|
||||||
|
// Display pending edits count if any
|
||||||
|
const pendingCount = await window.offlineDB.getPendingEditsCount();
|
||||||
|
if (pendingCount > 0) {
|
||||||
|
console.log(`${pendingCount} pending edits in queue`);
|
||||||
|
// Could show a badge in the UI here
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize offline database:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
12
app/ui/static/style.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/* Custom styles for Seismo Fleet Manager */
|
||||||
|
|
||||||
|
/* Additional custom styles can go here */
|
||||||
|
|
||||||
|
.card-hover {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
347
app/ui/static/sw.js
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
/* Service Worker for Seismo Fleet Manager PWA */
|
||||||
|
/* Network-first strategy with cache fallback for real-time data */
|
||||||
|
|
||||||
|
const CACHE_VERSION = 'v1';
|
||||||
|
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
|
||||||
|
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
|
||||||
|
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
|
||||||
|
|
||||||
|
// Files to precache (critical app shell)
|
||||||
|
const STATIC_FILES = [
|
||||||
|
'/',
|
||||||
|
'/static/style.css',
|
||||||
|
'/static/mobile.css',
|
||||||
|
'/static/mobile.js',
|
||||||
|
'/static/offline-db.js',
|
||||||
|
'/static/manifest.json',
|
||||||
|
'https://cdn.tailwindcss.com',
|
||||||
|
'https://unpkg.com/htmx.org@1.9.10',
|
||||||
|
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',
|
||||||
|
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install event - cache static files
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
console.log('[SW] Installing service worker...');
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(STATIC_CACHE)
|
||||||
|
.then((cache) => {
|
||||||
|
console.log('[SW] Precaching static files');
|
||||||
|
return cache.addAll(STATIC_FILES);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('[SW] Static files cached successfully');
|
||||||
|
return self.skipWaiting(); // Activate immediately
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[SW] Precaching failed:', error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event - clean up old caches
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('[SW] Activating service worker...');
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames.map((cacheName) => {
|
||||||
|
// Delete old caches that don't match current version
|
||||||
|
if (cacheName !== STATIC_CACHE &&
|
||||||
|
cacheName !== DYNAMIC_CACHE &&
|
||||||
|
cacheName !== DATA_CACHE) {
|
||||||
|
console.log('[SW] Deleting old cache:', cacheName);
|
||||||
|
return caches.delete(cacheName);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('[SW] Service worker activated');
|
||||||
|
return self.clients.claim(); // Take control of all pages
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch event - network-first strategy
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip non-GET requests
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip chrome-extension and other non-http(s) requests
|
||||||
|
if (!url.protocol.startsWith('http')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API requests - network first, cache fallback
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
event.respondWith(networkFirstStrategy(request, DATA_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets - cache first
|
||||||
|
if (isStaticAsset(url.pathname)) {
|
||||||
|
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML pages - network first with cache fallback
|
||||||
|
if (request.headers.get('accept')?.includes('text/html')) {
|
||||||
|
event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else - network first
|
||||||
|
event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Network-first strategy
|
||||||
|
async function networkFirstStrategy(request, cacheName) {
|
||||||
|
try {
|
||||||
|
// Try network first
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
|
||||||
|
// Cache successful responses
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
// Network failed, try cache
|
||||||
|
console.log('[SW] Network failed, trying cache:', request.url);
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
console.log('[SW] Serving from cache:', request.url);
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache available, return offline page or error
|
||||||
|
console.error('[SW] No cache available for:', request.url);
|
||||||
|
|
||||||
|
// For HTML requests, return a basic offline page
|
||||||
|
if (request.headers.get('accept')?.includes('text/html')) {
|
||||||
|
return new Response(
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Offline - SFM</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 { color: #f48b1c; margin-bottom: 1rem; }
|
||||||
|
p { margin-bottom: 1.5rem; color: #6b7280; }
|
||||||
|
button {
|
||||||
|
background: #f48b1c;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: #d97706; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>📡 You're Offline</h1>
|
||||||
|
<p>SFM requires an internet connection for this page.</p>
|
||||||
|
<p>Please check your connection and try again.</p>
|
||||||
|
<button onclick="location.reload()">Retry</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'text/html' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other requests, return error
|
||||||
|
return new Response('Network error', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first strategy
|
||||||
|
async function cacheFirstStrategy(request, cacheName) {
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in cache, fetch from network
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
|
||||||
|
// Cache successful responses
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Fetch failed:', request.url, error);
|
||||||
|
return new Response('Network error', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if URL is a static asset
|
||||||
|
function isStaticAsset(pathname) {
|
||||||
|
const staticExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.svg', '.ico', '.woff', '.woff2'];
|
||||||
|
return staticExtensions.some(ext => pathname.endsWith(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background Sync - for offline edits
|
||||||
|
self.addEventListener('sync', (event) => {
|
||||||
|
console.log('[SW] Background sync event:', event.tag);
|
||||||
|
|
||||||
|
if (event.tag === 'sync-edits') {
|
||||||
|
event.waitUntil(syncPendingEdits());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync pending edits to server
|
||||||
|
async function syncPendingEdits() {
|
||||||
|
console.log('[SW] Syncing pending edits...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get pending edits from IndexedDB
|
||||||
|
const db = await openDatabase();
|
||||||
|
const edits = await getPendingEdits(db);
|
||||||
|
|
||||||
|
if (edits.length === 0) {
|
||||||
|
console.log('[SW] No pending edits to sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SW] Syncing ${edits.length} pending edits`);
|
||||||
|
|
||||||
|
// Send edits to server
|
||||||
|
const response = await fetch('/api/sync-edits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ edits })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('[SW] Sync successful:', result);
|
||||||
|
|
||||||
|
// Clear synced edits from IndexedDB
|
||||||
|
await clearSyncedEdits(db, result.synced_ids || []);
|
||||||
|
|
||||||
|
// Notify all clients about successful sync
|
||||||
|
const clients = await self.clients.matchAll();
|
||||||
|
clients.forEach(client => {
|
||||||
|
client.postMessage({
|
||||||
|
type: 'SYNC_COMPLETE',
|
||||||
|
synced: result.synced
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('[SW] Sync failed:', response.status);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Sync error:', error);
|
||||||
|
throw error; // Will retry sync later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexedDB helpers (simplified versions - full implementations in offline-db.js)
|
||||||
|
function openDatabase() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open('sfm-offline-db', 1);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains('pending-edits')) {
|
||||||
|
db.createObjectStore('pending-edits', { keyPath: 'id', autoIncrement: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingEdits(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['pending-edits'], 'readonly');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSyncedEdits(db, editIds) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['pending-edits'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
|
||||||
|
editIds.forEach(id => {
|
||||||
|
store.delete(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message event - handle messages from clients
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
console.log('[SW] Message received:', event.data);
|
||||||
|
|
||||||
|
if (event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'CLEAR_CACHE') {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames.map((cacheName) => caches.delete(cacheName))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[SW] Service Worker loaded');
|
||||||
384
app/ui/templates/base.html
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% if environment == 'development' %}[DEV] {% endif %}{% block title %}Seismo Fleet Manager{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- HTMX -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
|
||||||
|
<!-- Leaflet for maps -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
||||||
|
<!-- Mobile CSS -->
|
||||||
|
<link rel="stylesheet" href="/static/mobile.css">
|
||||||
|
|
||||||
|
<!-- PWA Manifest -->
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<meta name="theme-color" content="#f48b1c">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="SFM">
|
||||||
|
|
||||||
|
<!-- Custom Tailwind Config -->
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
seismo: {
|
||||||
|
orange: '#f48b1c',
|
||||||
|
navy: '#142a66',
|
||||||
|
burgundy: '#7d234d',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions */
|
||||||
|
* {
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||||
|
|
||||||
|
<!-- Offline Indicator -->
|
||||||
|
<div id="offlineIndicator" class="offline-indicator">
|
||||||
|
📡 Offline - Changes will sync when connected
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Toast -->
|
||||||
|
<div id="syncToast" class="sync-toast">
|
||||||
|
✓ Synced successfully
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex h-screen overflow-hidden">
|
||||||
|
<!-- Sidebar (Responsive) -->
|
||||||
|
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h1 class="text-2xl font-bold text-seismo-navy dark:text-seismo-orange">
|
||||||
|
Seismo<br>
|
||||||
|
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center justify-between mt-2">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
|
||||||
|
{% if environment == 'development' %}
|
||||||
|
<span class="px-2 py-1 text-xs font-bold text-white bg-yellow-500 rounded">DEV</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="flex-1 p-4 space-y-2">
|
||||||
|
<a href="/" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/roster" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/roster' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||||
|
</svg>
|
||||||
|
Devices
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/seismographs" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/seismographs' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
Seismographs
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/sound-level-meters" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sound-level-meters' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
|
</svg>
|
||||||
|
Sound Level Meters
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="#" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 opacity-50 cursor-not-allowed">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||||
|
</svg>
|
||||||
|
Projects
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Dark mode toggle and utilities -->
|
||||||
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-2">
|
||||||
|
<button onclick="toggleDarkMode()" class="w-full flex items-center justify-center px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||||
|
<svg id="theme-toggle-dark-icon" class="w-5 h-5 hidden dark:block" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg id="theme-toggle-light-icon" class="w-5 h-5 block dark:hidden" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-3">Toggle theme</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="hardReload()" class="w-full flex items-center justify-center px-4 py-3 rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-400">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-3">Clear Cache & Reload</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Backdrop (Mobile Only) -->
|
||||||
|
<div id="backdrop" class="backdrop" onclick="closeMenuFromBackdrop()"></div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="main-content flex-1 overflow-y-auto">
|
||||||
|
<div class="p-8">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation (Mobile Only) -->
|
||||||
|
<nav class="bottom-nav">
|
||||||
|
<div class="grid grid-cols-4 h-16">
|
||||||
|
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Menu</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-href="/" onclick="window.location.href='/'">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-href="/roster" onclick="window.location.href='/roster'">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Devices</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Dark mode toggle
|
||||||
|
function toggleDarkMode() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
if (html.classList.contains('dark')) {
|
||||||
|
html.classList.remove('dark');
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
} else {
|
||||||
|
html.classList.add('dark');
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard reload function - clears all caches and reloads
|
||||||
|
async function hardReload() {
|
||||||
|
try {
|
||||||
|
// Clear service worker caches
|
||||||
|
if ('caches' in window) {
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||||
|
console.log('Cleared all service worker caches');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister service workers
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
await Promise.all(registrations.map(reg => reg.unregister()));
|
||||||
|
console.log('Unregistered all service workers');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear IndexedDB
|
||||||
|
if ('indexedDB' in window) {
|
||||||
|
try {
|
||||||
|
indexedDB.deleteDatabase('sfm-offline-db');
|
||||||
|
console.log('Cleared IndexedDB');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Could not clear IndexedDB:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force reload with cache bypass
|
||||||
|
window.location.reload(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during hard reload:', error);
|
||||||
|
// Fallback to regular reload
|
||||||
|
window.location.reload(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved theme preference
|
||||||
|
if (localStorage.getItem('theme') === 'light') {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
} else if (localStorage.getItem('theme') === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function: Convert timestamp to relative time
|
||||||
|
function timeAgo(dateString) {
|
||||||
|
if (!dateString) return 'Never';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const seconds = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) {
|
||||||
|
const remainingMins = minutes % 60;
|
||||||
|
return remainingMins > 0 ? `${hours}h ${remainingMins}m ago` : `${hours}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) {
|
||||||
|
const remainingHours = hours % 24;
|
||||||
|
return remainingHours > 0 ? `${days}d ${remainingHours}h ago` : `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
if (weeks < 4) {
|
||||||
|
const remainingDays = days % 7;
|
||||||
|
return remainingDays > 0 ? `${weeks}w ${remainingDays}d ago` : `${weeks}w ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
return `${months}mo ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function: Get user's selected timezone
|
||||||
|
function getTimezone() {
|
||||||
|
return localStorage.getItem('timezone') || 'America/New_York';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function: Format timestamp with tooltip (timezone-aware)
|
||||||
|
function formatTimestamp(dateString) {
|
||||||
|
if (!dateString) return '<span class="text-gray-400">Never</span>';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const timezone = getTimezone();
|
||||||
|
|
||||||
|
const fullDate = date.toLocaleString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<span title="${fullDate}" class="cursor-help">${timeAgo(dateString)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function: Format timestamp as full date/time (no relative time)
|
||||||
|
// Format: "9/10/2020 8:00 AM EST"
|
||||||
|
function formatFullTimestamp(dateString) {
|
||||||
|
if (!dateString) return 'Never';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const timezone = getTimezone();
|
||||||
|
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all timestamps on page load and periodically
|
||||||
|
function updateAllTimestamps() {
|
||||||
|
document.querySelectorAll('[data-timestamp]').forEach(el => {
|
||||||
|
const timestamp = el.getAttribute('data-timestamp');
|
||||||
|
el.innerHTML = formatTimestamp(timestamp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on load and every minute
|
||||||
|
updateAllTimestamps();
|
||||||
|
setInterval(updateAllTimestamps, 60000);
|
||||||
|
|
||||||
|
// Copy to clipboard helper
|
||||||
|
function copyToClipboard(text, button) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
// Visual feedback
|
||||||
|
const originalHTML = button.innerHTML;
|
||||||
|
button.innerHTML = '<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
|
||||||
|
button.classList.add('text-green-500');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = originalHTML;
|
||||||
|
button.classList.remove('text-green-500');
|
||||||
|
}, 1500);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Offline Database -->
|
||||||
|
<script src="/static/offline-db.js?v=0.4.0"></script>
|
||||||
|
|
||||||
|
<!-- Mobile JavaScript -->
|
||||||
|
<script src="/static/mobile.js?v=0.4.0"></script>
|
||||||
|
|
||||||
|
{% block extra_scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
656
app/ui/templates/dashboard.html
Normal file
@@ -0,0 +1,656 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if environment == 'development' %}
|
||||||
|
<div class="mb-4 p-4 bg-yellow-100 dark:bg-yellow-900 border-l-4 border-yellow-500 text-yellow-700 dark:text-yellow-200 rounded">
|
||||||
|
<p class="font-bold">Development Environment</p>
|
||||||
|
<p class="text-sm">You are currently viewing the development version of Seismo Fleet Manager.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-8 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Last updated</p>
|
||||||
|
<p id="last-refresh" class="text-sm text-gray-700 dark:text-gray-300 font-mono">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard cards with auto-refresh -->
|
||||||
|
<div hx-get="/api/status-snapshot"
|
||||||
|
hx-trigger="load, every 10s"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="updateDashboard(event)">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
|
||||||
|
<!-- Fleet Summary Card -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-summary-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-summary-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 card-content" id="fleet-summary-content">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
||||||
|
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
|
||||||
|
<span id="deployed-units" class="text-3xl md:text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
||||||
|
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
||||||
|
<div class="flex justify-between items-center mb-1">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
||||||
|
</div>
|
||||||
|
<span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/sound-level-meters" class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
||||||
|
</div>
|
||||||
|
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
|
||||||
|
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
||||||
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">OK</span>
|
||||||
|
</div>
|
||||||
|
<span id="status-ok" class="font-semibold text-green-600 dark:text-green-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center mb-2" title="Units with delayed reports (12-24 hours)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-yellow-500 mr-2 flex items-center justify-center">
|
||||||
|
<svg class="w-2 h-2 text-white" 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>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Pending</span>
|
||||||
|
</div>
|
||||||
|
<span id="status-pending" class="font-semibold text-yellow-600 dark:text-yellow-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center" title="Units not reporting (> 24 hours)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-red-500 mr-2 flex items-center justify-center">
|
||||||
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Missing</span>
|
||||||
|
</div>
|
||||||
|
<span id="status-missing" class="font-semibold text-red-600 dark:text-red-400">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Alerts Card -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-alerts-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recently Called In Units Card -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-callins-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content" id="recent-callins-content">
|
||||||
|
<div id="recent-callins-list" class="space-y-2">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
||||||
|
</div>
|
||||||
|
<button id="show-all-callins" class="hidden mt-3 w-full text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
||||||
|
Show all recent call-ins
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fleet Map -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="fleet-map-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed units</span>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-map-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content" id="fleet-map-content">
|
||||||
|
<div id="fleet-map" class="w-full h-64 md:h-96 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Photos Section -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="recent-photos-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-photos-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content" id="recent-photos-content">
|
||||||
|
<div id="recentPhotosGallery" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading recent photos...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fleet Status Section with Tabs -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-status-card">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-status')">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center" onclick="event.stopPropagation()">
|
||||||
|
Full Roster
|
||||||
|
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-status-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content" id="fleet-status-content">
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium tab-button active-tab"
|
||||||
|
hx-get="/dashboard/active"
|
||||||
|
hx-target="#fleet-table"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Active
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium tab-button"
|
||||||
|
hx-get="/dashboard/benched"
|
||||||
|
hx-target="#fleet-table"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Benched
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content Target -->
|
||||||
|
<div id="fleet-table" class="space-y-2"
|
||||||
|
hx-get="/dashboard/active"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- TAB STYLE -->
|
||||||
|
<style>
|
||||||
|
.tab-button {
|
||||||
|
color: #6b7280; /* gray-500 */
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tab-button:hover {
|
||||||
|
color: #374151; /* gray-700 */
|
||||||
|
}
|
||||||
|
.active-tab {
|
||||||
|
color: #b84a12 !important; /* seismo orange */
|
||||||
|
border-bottom: 2px solid #b84a12 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible cards (mobile only) */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.card-content.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.chevron.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Toggle card collapse/expand (mobile only)
|
||||||
|
function toggleCard(cardName) {
|
||||||
|
// Only work on mobile
|
||||||
|
if (window.innerWidth >= 768) return;
|
||||||
|
|
||||||
|
const content = document.getElementById(`${cardName}-content`);
|
||||||
|
const chevron = document.getElementById(`${cardName}-chevron`);
|
||||||
|
|
||||||
|
if (!content || !chevron) return;
|
||||||
|
|
||||||
|
// Toggle collapsed state
|
||||||
|
const isCollapsed = content.classList.contains('collapsed');
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
content.classList.remove('collapsed');
|
||||||
|
chevron.classList.remove('collapsed');
|
||||||
|
|
||||||
|
// If expanding the fleet map, invalidate size after animation
|
||||||
|
if (cardName === 'fleet-map' && window.fleetMap) {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.fleetMap.invalidateSize();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content.classList.add('collapsed');
|
||||||
|
chevron.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save state to localStorage
|
||||||
|
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||||
|
cardStates[cardName] = !isCollapsed;
|
||||||
|
localStorage.setItem('dashboardCardStates', JSON.stringify(cardStates));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore card states from localStorage on page load
|
||||||
|
function restoreCardStates() {
|
||||||
|
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||||
|
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
|
||||||
|
|
||||||
|
cardNames.forEach(cardName => {
|
||||||
|
const content = document.getElementById(`${cardName}-content`);
|
||||||
|
const chevron = document.getElementById(`${cardName}-chevron`);
|
||||||
|
|
||||||
|
if (!content || !chevron) return;
|
||||||
|
|
||||||
|
// Default to expanded (true) if no saved state
|
||||||
|
const isCollapsed = cardStates[cardName] === false;
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
content.classList.add('collapsed');
|
||||||
|
chevron.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore states when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', restoreCardStates);
|
||||||
|
} else {
|
||||||
|
restoreCardStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboard(event) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.detail.xhr.response);
|
||||||
|
|
||||||
|
// Update "Last updated" timestamp with timezone
|
||||||
|
const now = new Date();
|
||||||
|
const timezone = localStorage.getItem('timezone') || 'America/New_York';
|
||||||
|
document.getElementById('last-refresh').textContent = now.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Fleet summary numbers =====
|
||||||
|
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||||
|
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||||
|
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
||||||
|
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
||||||
|
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||||
|
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||||
|
|
||||||
|
// ===== Device type counts =====
|
||||||
|
let seismoCount = 0;
|
||||||
|
let slmCount = 0;
|
||||||
|
Object.values(data.units || {}).forEach(unit => {
|
||||||
|
if (unit.retired) return; // Don't count retired units
|
||||||
|
const deviceType = unit.device_type || 'seismograph';
|
||||||
|
if (deviceType === 'seismograph') {
|
||||||
|
seismoCount++;
|
||||||
|
} else if (deviceType === 'sound_level_meter') {
|
||||||
|
slmCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('seismo-count').textContent = seismoCount;
|
||||||
|
document.getElementById('slm-count').textContent = slmCount;
|
||||||
|
|
||||||
|
// ===== Alerts =====
|
||||||
|
const alertsList = document.getElementById('alerts-list');
|
||||||
|
// Only show alerts for deployed units that are MISSING (not pending)
|
||||||
|
const missingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Missing');
|
||||||
|
|
||||||
|
if (!missingUnits.length) {
|
||||||
|
alertsList.innerHTML =
|
||||||
|
'<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
|
||||||
|
} else {
|
||||||
|
let alertsHtml = '';
|
||||||
|
|
||||||
|
missingUnits.forEach(([id, unit]) => {
|
||||||
|
alertsHtml += `
|
||||||
|
<div class="flex items-start space-x-2 text-sm">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
|
||||||
|
<div>
|
||||||
|
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
alertsList.innerHTML = alertsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Update Fleet Map =====
|
||||||
|
updateFleetMap(data);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Dashboard update error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tab switching
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const tabButtons = document.querySelectorAll('.tab-button');
|
||||||
|
|
||||||
|
tabButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
// Remove active-tab class from all buttons
|
||||||
|
tabButtons.forEach(btn => btn.classList.remove('active-tab'));
|
||||||
|
// Add active-tab class to clicked button
|
||||||
|
this.classList.add('active-tab');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize fleet map
|
||||||
|
initFleetMap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let fleetMap = null;
|
||||||
|
let fleetMarkers = [];
|
||||||
|
let fleetMapInitialized = false;
|
||||||
|
|
||||||
|
// Make fleetMap accessible globally for toggleCard function
|
||||||
|
window.fleetMap = null;
|
||||||
|
|
||||||
|
function initFleetMap() {
|
||||||
|
// Initialize the map centered on the US (can adjust based on your deployment area)
|
||||||
|
fleetMap = L.map('fleet-map').setView([39.8283, -98.5795], 4);
|
||||||
|
window.fleetMap = fleetMap;
|
||||||
|
|
||||||
|
// Add OpenStreetMap tiles
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
maxZoom: 18
|
||||||
|
}).addTo(fleetMap);
|
||||||
|
|
||||||
|
// Force map to recalculate size after a brief delay to ensure container is fully rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
fleetMap.invalidateSize();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFleetMap(data) {
|
||||||
|
if (!fleetMap) return;
|
||||||
|
|
||||||
|
// Clear existing markers
|
||||||
|
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
||||||
|
fleetMarkers = [];
|
||||||
|
|
||||||
|
// Get deployed units with coordinates data
|
||||||
|
const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.coordinates);
|
||||||
|
|
||||||
|
if (deployedUnits.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = [];
|
||||||
|
|
||||||
|
deployedUnits.forEach(([id, unit]) => {
|
||||||
|
const coords = parseLocation(unit.coordinates);
|
||||||
|
if (coords) {
|
||||||
|
const [lat, lon] = coords;
|
||||||
|
|
||||||
|
// Create marker with custom color based on status
|
||||||
|
const markerColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'orange' : 'red';
|
||||||
|
|
||||||
|
const marker = L.circleMarker([lat, lon], {
|
||||||
|
radius: 8,
|
||||||
|
fillColor: markerColor,
|
||||||
|
color: '#fff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.8
|
||||||
|
}).addTo(fleetMap);
|
||||||
|
|
||||||
|
// Add popup with unit info
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div class="p-2">
|
||||||
|
<h3 class="font-bold text-lg">${id}</h3>
|
||||||
|
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
||||||
|
<p class="text-sm">Type: ${unit.device_type}</p>
|
||||||
|
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
|
||||||
|
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details →</a>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
fleetMarkers.push(marker);
|
||||||
|
bounds.push([lat, lon]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit map to show all markers
|
||||||
|
if (bounds.length > 0) {
|
||||||
|
// Use different padding for mobile vs desktop
|
||||||
|
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
|
||||||
|
fleetMap.fitBounds(bounds, { padding: padding });
|
||||||
|
fleetMapInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocation(location) {
|
||||||
|
if (!location) return null;
|
||||||
|
|
||||||
|
// Try to parse as "lat,lon" format
|
||||||
|
const parts = location.split(',').map(s => s.trim());
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const lat = parseFloat(parts[0]);
|
||||||
|
const lon = parseFloat(parts[1]);
|
||||||
|
if (!isNaN(lat) && !isNaN(lon)) {
|
||||||
|
return [lat, lon];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add geocoding support for address strings
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and display recent photos
|
||||||
|
async function loadRecentPhotos() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/recent-photos?limit=12');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load recent photos');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const gallery = document.getElementById('recentPhotosGallery');
|
||||||
|
|
||||||
|
if (data.photos && data.photos.length > 0) {
|
||||||
|
gallery.innerHTML = '';
|
||||||
|
data.photos.forEach(photo => {
|
||||||
|
const photoDiv = document.createElement('div');
|
||||||
|
photoDiv.className = 'relative group';
|
||||||
|
photoDiv.innerHTML = `
|
||||||
|
<a href="/unit/${photo.unit_id}" class="block">
|
||||||
|
<img src="${photo.path}" alt="${photo.unit_id}"
|
||||||
|
class="w-full h-32 object-cover rounded-lg shadow hover:shadow-lg transition-shadow">
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 rounded-b-lg">
|
||||||
|
<p class="text-white text-xs font-semibold">${photo.unit_id}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
gallery.appendChild(photoDiv);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos uploaded yet. Upload photos from unit detail pages.</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recent photos:', error);
|
||||||
|
document.getElementById('recentPhotosGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load recent photos</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load recent photos on page load and refresh every 30 seconds
|
||||||
|
loadRecentPhotos();
|
||||||
|
setInterval(loadRecentPhotos, 30000);
|
||||||
|
|
||||||
|
// Load and display recent call-ins
|
||||||
|
let showingAllCallins = false;
|
||||||
|
const DEFAULT_CALLINS_DISPLAY = 5;
|
||||||
|
|
||||||
|
async function loadRecentCallins() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/recent-callins?hours=6');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load recent call-ins');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const callinsList = document.getElementById('recent-callins-list');
|
||||||
|
const showAllButton = document.getElementById('show-all-callins');
|
||||||
|
|
||||||
|
if (data.call_ins && data.call_ins.length > 0) {
|
||||||
|
// Determine how many to show
|
||||||
|
const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length);
|
||||||
|
const callinsToDisplay = data.call_ins.slice(0, displayCount);
|
||||||
|
|
||||||
|
// Build HTML for call-ins list
|
||||||
|
let html = '';
|
||||||
|
callinsToDisplay.forEach(callin => {
|
||||||
|
// Status color
|
||||||
|
const statusColor = callin.status === 'OK' ? 'green' : callin.status === 'Pending' ? 'yellow' : 'red';
|
||||||
|
const statusClass = callin.status === 'OK' ? 'bg-green-500' : callin.status === 'Pending' ? 'bg-yellow-500' : 'bg-red-500';
|
||||||
|
|
||||||
|
// Build location/note line
|
||||||
|
let subtitle = '';
|
||||||
|
if (callin.location) {
|
||||||
|
subtitle = callin.location;
|
||||||
|
} else if (callin.note) {
|
||||||
|
subtitle = callin.note;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="w-2 h-2 rounded-full ${statusClass}"></span>
|
||||||
|
<div>
|
||||||
|
<a href="/unit/${callin.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
|
||||||
|
${callin.unit_id}
|
||||||
|
</a>
|
||||||
|
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400">${subtitle}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">${callin.time_ago}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
callinsList.innerHTML = html;
|
||||||
|
|
||||||
|
// Show/hide the "Show all" button
|
||||||
|
if (data.call_ins.length > DEFAULT_CALLINS_DISPLAY) {
|
||||||
|
showAllButton.classList.remove('hidden');
|
||||||
|
showAllButton.textContent = showingAllCallins
|
||||||
|
? `Show fewer (${DEFAULT_CALLINS_DISPLAY})`
|
||||||
|
: `Show all (${data.call_ins.length})`;
|
||||||
|
} else {
|
||||||
|
showAllButton.classList.add('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No units have called in within the past 6 hours</p>';
|
||||||
|
showAllButton.classList.add('hidden');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recent call-ins:', error);
|
||||||
|
document.getElementById('recent-callins-list').innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle show all/show fewer
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const showAllButton = document.getElementById('show-all-callins');
|
||||||
|
showAllButton.addEventListener('click', function() {
|
||||||
|
showingAllCallins = !showingAllCallins;
|
||||||
|
loadRecentCallins();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load recent call-ins on page load and refresh every 30 seconds
|
||||||
|
loadRecentCallins();
|
||||||
|
setInterval(loadRecentCallins, 30000);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
56
app/ui/templates/partials/active_table.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{% if units %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for unit_id, unit in units.items() %}
|
||||||
|
<div class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<div class="flex items-center space-x-3 flex-1">
|
||||||
|
<!-- Status Indicator -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{% if unit.status == 'OK' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||||
|
{% elif unit.status == 'Pending' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit Info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/unit/{{ unit_id }}" class="font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||||
|
{{ unit_id }}
|
||||||
|
</a>
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if unit.note %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ unit.note }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Age -->
|
||||||
|
<div class="text-right flex-shrink-0">
|
||||||
|
<span class="text-sm {% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400{% else %}text-gray-500 dark:text-gray-400{% endif %}">
|
||||||
|
{{ unit.age }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No active units</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a new unit to the fleet.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
56
app/ui/templates/partials/benched_table.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{% if units %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for unit_id, unit in units.items() %}
|
||||||
|
<div class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<div class="flex items-center space-x-3 flex-1">
|
||||||
|
<!-- Status Indicator (grayed out for benched) -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<span class="w-4 h-4 rounded-full bg-gray-400" title="Benched"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit Info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/unit/{{ unit_id }}" class="font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||||
|
{{ unit_id }}
|
||||||
|
</a>
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if unit.note %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ unit.note }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Activity (if any) -->
|
||||||
|
<div class="text-right flex-shrink-0">
|
||||||
|
{% if unit.age != 'N/A' %}
|
||||||
|
<span class="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
Last seen: {{ unit.age }} ago
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
No data
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No benched units</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Units awaiting deployment will appear here.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
23
app/ui/templates/partials/dashboard_active.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% for id, unit in units.items() %}
|
||||||
|
<a href="/unit/{{ id }}"
|
||||||
|
class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700">
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="w-3 h-3 rounded-full
|
||||||
|
{% if unit.status == 'OK' %} bg-green-500
|
||||||
|
{% elif unit.status == 'Pending' %} bg-yellow-500
|
||||||
|
{% else %} bg-red-500 {% endif %}">
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
|
||||||
|
|
||||||
|
<span class="font-medium">{{ id }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ unit.age }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if units|length == 0 %}
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">No active units</p>
|
||||||
|
{% endif %}
|
||||||
23
app/ui/templates/partials/dashboard_benched.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% for id, unit in units.items() %}
|
||||||
|
<a href="/unit/{{ id }}"
|
||||||
|
class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700">
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="w-3 h-3 rounded-full
|
||||||
|
{% if unit.status == 'OK' %} bg-green-500
|
||||||
|
{% elif unit.status == 'Pending' %} bg-yellow-500
|
||||||
|
{% else %} bg-red-500 {% endif %}">
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- No deployed dot for benched units -->
|
||||||
|
|
||||||
|
<span class="font-medium">{{ id }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ unit.age }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if units|length == 0 %}
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">No benched units</p>
|
||||||
|
{% endif %}
|
||||||
449
app/ui/templates/partials/devices_table.html
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
<!-- Desktop Table View -->
|
||||||
|
<div class="hidden md:block rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||||
|
<table id="roster-table" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('status')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Status
|
||||||
|
<span class="sort-indicator" data-column="status"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('id')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Unit ID
|
||||||
|
<span class="sort-indicator" data-column="id"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('type')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Type
|
||||||
|
<span class="sort-indicator" data-column="type"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Details
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('last_seen')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Last Seen
|
||||||
|
<span class="sort-indicator" data-column="last_seen"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('age')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Age
|
||||||
|
<span class="sort-indicator" data-column="age"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('note')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Note
|
||||||
|
<span class="sort-indicator" data-column="note"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="roster-tbody" class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for unit in units %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
data-device-type="{{ unit.device_type }}"
|
||||||
|
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||||
|
data-health="{{ unit.status }}"
|
||||||
|
data-id="{{ unit.id }}"
|
||||||
|
data-type="{{ unit.device_type }}"
|
||||||
|
data-last-seen="{{ unit.last_seen }}"
|
||||||
|
data-age="{{ unit.age }}"
|
||||||
|
data-note="{{ unit.note if unit.note else '' }}">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
{% if unit.status == 'OK' %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||||
|
{% elif unit.status == 'Pending' %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-red-500" title="Missing"></span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||||
|
{{ unit.id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
{% if unit.ip_address %}
|
||||||
|
<div><span class="font-mono">{{ unit.ip_address }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if unit.phone_number %}
|
||||||
|
<div>{{ unit.phone_number }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if unit.hardware_model %}
|
||||||
|
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if unit.next_calibration_due %}
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
|
||||||
|
<span class="font-medium">{{ unit.next_calibration_due }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-500">Modem:</span>
|
||||||
|
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-seismo-orange hover:underline font-medium">
|
||||||
|
{{ unit.deployed_with_modem_id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm
|
||||||
|
{% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold
|
||||||
|
{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400
|
||||||
|
{% else %}text-gray-500 dark:text-gray-400
|
||||||
|
{% endif %}">
|
||||||
|
{{ unit.age }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs" title="{{ unit.note }}">
|
||||||
|
{{ unit.note if unit.note else '-' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="editUnit('{{ unit.id }}')"
|
||||||
|
class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<button onclick="toggleDeployed('{{ unit.id }}', false)"
|
||||||
|
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-1" title="Mark as Benched">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button onclick="toggleDeployed('{{ unit.id }}', true)"
|
||||||
|
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Mark as Deployed">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="moveToIgnore('{{ unit.id }}')"
|
||||||
|
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-1" title="Move to Ignore List">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteUnit('{{ unit.id }}')"
|
||||||
|
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Last updated indicator -->
|
||||||
|
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||||
|
Last updated: <span id="last-updated">{{ timestamp }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Card View -->
|
||||||
|
<div class="md:hidden space-y-3">
|
||||||
|
{% for unit in units %}
|
||||||
|
<div class="unit-card device-card"
|
||||||
|
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||||
|
data-device-type="{{ unit.device_type }}"
|
||||||
|
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||||
|
data-health="{{ unit.status }}"
|
||||||
|
data-unit-id="{{ unit.id }}"
|
||||||
|
data-age="{{ unit.age }}">
|
||||||
|
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if unit.status == 'OK' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||||
|
{% elif unit.status == 'Pending' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
|
{% elif unit.status == 'Missing' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-gray-400" title="No Data"></span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||||
|
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||||
|
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||||
|
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||||
|
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||||
|
{% endif %}">
|
||||||
|
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type Badge -->
|
||||||
|
<div class="mb-2">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
{% if unit.address %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
📍 {{ unit.address }}
|
||||||
|
</div>
|
||||||
|
{% elif unit.coordinates %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
📍 {{ unit.coordinates }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Project ID -->
|
||||||
|
{% if unit.project_id %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
🏗️ {{ unit.project_id }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Last Seen -->
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
||||||
|
🕐 {{ unit.age }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deployed/Benched Indicator -->
|
||||||
|
<div class="mt-2">
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
⚡ Deployed
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
📦 Benched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tap Hint -->
|
||||||
|
<div class="text-xs text-gray-400 mt-2 text-center border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||||
|
Tap for details
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Mobile Last Updated -->
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||||
|
Last updated: <span id="last-updated-mobile">{{ timestamp }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit Detail Modal -->
|
||||||
|
<div id="unitModal" class="unit-modal">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="unit-modal-backdrop" onclick="closeUnitModal(event)"></div>
|
||||||
|
|
||||||
|
<!-- Modal Content -->
|
||||||
|
<div class="unit-modal-content">
|
||||||
|
<!-- Handle Bar (Mobile Only) -->
|
||||||
|
<div class="modal-handle"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 id="modalUnitId" class="text-xl font-bold text-gray-900 dark:text-white"></h3>
|
||||||
|
<button onclick="closeUnitModal(event)" data-close-modal class="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||||
|
<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"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div id="modalContent" class="p-6">
|
||||||
|
<!-- Content will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||||
|
<button id="modalEditBtn" class="w-full h-12 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
||||||
|
Edit Unit
|
||||||
|
</button>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button id="modalDeployBtn" class="h-12 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Deploy/Bench
|
||||||
|
</button>
|
||||||
|
<button id="modalDeleteBtn" class="h-12 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sort-indicator::after {
|
||||||
|
content: '⇅';
|
||||||
|
opacity: 0.3;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.sort-indicator.asc::after {
|
||||||
|
content: '↑';
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.sort-indicator.desc::after {
|
||||||
|
content: '↓';
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Update timestamp
|
||||||
|
const timestampElement = document.getElementById('last-updated');
|
||||||
|
if (timestampElement) {
|
||||||
|
timestampElement.textContent = new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
const timestampMobileElement = document.getElementById('last-updated-mobile');
|
||||||
|
if (timestampMobileElement) {
|
||||||
|
timestampMobileElement.textContent = new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a lightweight status map around for the mobile modal
|
||||||
|
const rosterUnits = {{ units | tojson }};
|
||||||
|
window.rosterStatusMap = rosterUnits.reduce((acc, unit) => {
|
||||||
|
acc[unit.id] = {
|
||||||
|
status: unit.status || 'Unknown',
|
||||||
|
age: unit.age || 'N/A',
|
||||||
|
last: unit.last_seen || 'Never'
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Sorting state
|
||||||
|
let currentSort = { column: null, direction: 'asc' };
|
||||||
|
|
||||||
|
function sortTable(column) {
|
||||||
|
const tbody = document.getElementById('roster-tbody');
|
||||||
|
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||||
|
|
||||||
|
// Determine sort direction
|
||||||
|
if (currentSort.column === column) {
|
||||||
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort.column = column;
|
||||||
|
currentSort.direction = 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort rows
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
let aVal = a.getAttribute(`data-${column}`) || '';
|
||||||
|
let bVal = b.getAttribute(`data-${column}`) || '';
|
||||||
|
|
||||||
|
// Special handling for different column types
|
||||||
|
if (column === 'age') {
|
||||||
|
// Parse age strings like "2h 15m" or "45m" or "3d 5h"
|
||||||
|
aVal = parseAge(aVal);
|
||||||
|
bVal = parseAge(bVal);
|
||||||
|
} else if (column === 'status') {
|
||||||
|
// Sort by status priority: Missing > Pending > OK
|
||||||
|
const statusOrder = { 'Missing': 0, 'Pending': 1, 'OK': 2, '': 3 };
|
||||||
|
aVal = statusOrder[aVal] !== undefined ? statusOrder[aVal] : 999;
|
||||||
|
bVal = statusOrder[bVal] !== undefined ? statusOrder[bVal] : 999;
|
||||||
|
} else if (column === 'last_seen') {
|
||||||
|
// Sort by date
|
||||||
|
aVal = new Date(aVal).getTime() || 0;
|
||||||
|
bVal = new Date(bVal).getTime() || 0;
|
||||||
|
} else {
|
||||||
|
// String comparison (case-insensitive)
|
||||||
|
aVal = aVal.toLowerCase();
|
||||||
|
bVal = bVal.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
||||||
|
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-append rows in sorted order
|
||||||
|
rows.forEach(row => tbody.appendChild(row));
|
||||||
|
|
||||||
|
// Update sort indicators
|
||||||
|
updateSortIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAge(ageStr) {
|
||||||
|
// Parse age strings like "2h 15m", "45m", "3d 5h", "2w 3d"
|
||||||
|
if (!ageStr) return 0;
|
||||||
|
|
||||||
|
let totalMinutes = 0;
|
||||||
|
const weeks = ageStr.match(/(\d+)w/);
|
||||||
|
const days = ageStr.match(/(\d+)d/);
|
||||||
|
const hours = ageStr.match(/(\d+)h/);
|
||||||
|
const minutes = ageStr.match(/(\d+)m/);
|
||||||
|
|
||||||
|
if (weeks) totalMinutes += parseInt(weeks[1]) * 7 * 24 * 60;
|
||||||
|
if (days) totalMinutes += parseInt(days[1]) * 24 * 60;
|
||||||
|
if (hours) totalMinutes += parseInt(hours[1]) * 60;
|
||||||
|
if (minutes) totalMinutes += parseInt(minutes[1]);
|
||||||
|
|
||||||
|
return totalMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortIndicators() {
|
||||||
|
// Clear all indicators
|
||||||
|
document.querySelectorAll('.sort-indicator').forEach(indicator => {
|
||||||
|
indicator.className = 'sort-indicator';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set current indicator
|
||||||
|
if (currentSort.column) {
|
||||||
|
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
||||||
|
if (indicator) {
|
||||||
|
indicator.className = `sort-indicator ${currentSort.direction}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
69
app/ui/templates/partials/ignored_table.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Unit ID
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Reason
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Ignored At
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% if ignored_units %}
|
||||||
|
{% for unit in ignored_units %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-400" title="Ignored"></span>
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ unit.id }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{ unit.reason }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ unit.ignored_at }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="unignoreUnit('{{ unit.id }}')"
|
||||||
|
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Un-ignore Unit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteIgnoredUnit('{{ unit.id }}')"
|
||||||
|
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No ignored units
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Last updated indicator -->
|
||||||
|
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||||
|
Last updated: {{ timestamp }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
77
app/ui/templates/partials/retired_table.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Unit ID
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Note
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% if units %}
|
||||||
|
{% for unit in units %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-500" title="Retired"></span>
|
||||||
|
<a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||||
|
{{ unit.id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{ unit.note }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="editUnit('{{ unit.id }}')"
|
||||||
|
class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteUnit('{{ unit.id }}')"
|
||||||
|
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No retired units
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Last updated indicator -->
|
||||||
|
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||||
|
Last updated: {{ timestamp }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
445
app/ui/templates/partials/roster_table.html
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
<!-- Desktop Table View -->
|
||||||
|
<div class="hidden md:block rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||||
|
<table id="roster-table" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('status')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Status
|
||||||
|
<span class="sort-indicator" data-column="status"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('id')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Unit ID
|
||||||
|
<span class="sort-indicator" data-column="id"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('type')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Type
|
||||||
|
<span class="sort-indicator" data-column="type"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Details
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('last_seen')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Last Seen
|
||||||
|
<span class="sort-indicator" data-column="last_seen"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('age')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Age
|
||||||
|
<span class="sort-indicator" data-column="age"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('note')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Note
|
||||||
|
<span class="sort-indicator" data-column="note"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="roster-tbody" class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for unit in units %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
data-status="{{ unit.status }}"
|
||||||
|
data-id="{{ unit.id }}"
|
||||||
|
data-type="{{ unit.device_type }}"
|
||||||
|
data-last-seen="{{ unit.last_seen }}"
|
||||||
|
data-age="{{ unit.age }}"
|
||||||
|
data-note="{{ unit.note if unit.note else '' }}">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
{% if unit.status == 'OK' %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||||
|
{% elif unit.status == 'Pending' %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-red-500" title="Missing"></span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||||
|
{{ unit.id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
{% if unit.ip_address %}
|
||||||
|
<div><span class="font-mono">{{ unit.ip_address }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if unit.phone_number %}
|
||||||
|
<div>{{ unit.phone_number }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if unit.hardware_model %}
|
||||||
|
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if unit.next_calibration_due %}
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
|
||||||
|
<span class="font-medium">{{ unit.next_calibration_due }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-500">Modem:</span>
|
||||||
|
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-seismo-orange hover:underline font-medium">
|
||||||
|
{{ unit.deployed_with_modem_id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm
|
||||||
|
{% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold
|
||||||
|
{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400
|
||||||
|
{% else %}text-gray-500 dark:text-gray-400
|
||||||
|
{% endif %}">
|
||||||
|
{{ unit.age }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs" title="{{ unit.note }}">
|
||||||
|
{{ unit.note if unit.note else '-' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="editUnit('{{ unit.id }}')"
|
||||||
|
class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<button onclick="toggleDeployed('{{ unit.id }}', false)"
|
||||||
|
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-1" title="Mark as Benched">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button onclick="toggleDeployed('{{ unit.id }}', true)"
|
||||||
|
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Mark as Deployed">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="moveToIgnore('{{ unit.id }}')"
|
||||||
|
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-1" title="Move to Ignore List">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteUnit('{{ unit.id }}')"
|
||||||
|
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Last updated indicator -->
|
||||||
|
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||||
|
Last updated: <span id="last-updated">{{ timestamp }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Card View -->
|
||||||
|
<div class="md:hidden space-y-3">
|
||||||
|
{% for unit in units %}
|
||||||
|
<div class="unit-card"
|
||||||
|
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||||
|
data-unit-id="{{ unit.id }}"
|
||||||
|
data-status="{{ unit.status }}"
|
||||||
|
data-age="{{ unit.age }}">
|
||||||
|
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if unit.status == 'OK' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||||
|
{% elif unit.status == 'Pending' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
|
{% elif unit.status == 'Missing' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-gray-400" title="No Data"></span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||||
|
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||||
|
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||||
|
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||||
|
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||||
|
{% endif %}">
|
||||||
|
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type Badge -->
|
||||||
|
<div class="mb-2">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
{% if unit.address %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
📍 {{ unit.address }}
|
||||||
|
</div>
|
||||||
|
{% elif unit.coordinates %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
📍 {{ unit.coordinates }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Project ID -->
|
||||||
|
{% if unit.project_id %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
🏗️ {{ unit.project_id }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Last Seen -->
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
||||||
|
🕐 {{ unit.age }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deployed/Benched Indicator -->
|
||||||
|
<div class="mt-2">
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
⚡ Deployed
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
📦 Benched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tap Hint -->
|
||||||
|
<div class="text-xs text-gray-400 mt-2 text-center border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||||
|
Tap for details
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Mobile Last Updated -->
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||||
|
Last updated: <span id="last-updated-mobile">{{ timestamp }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit Detail Modal -->
|
||||||
|
<div id="unitModal" class="unit-modal">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="unit-modal-backdrop" onclick="closeUnitModal(event)"></div>
|
||||||
|
|
||||||
|
<!-- Modal Content -->
|
||||||
|
<div class="unit-modal-content">
|
||||||
|
<!-- Handle Bar (Mobile Only) -->
|
||||||
|
<div class="modal-handle"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 id="modalUnitId" class="text-xl font-bold text-gray-900 dark:text-white"></h3>
|
||||||
|
<button onclick="closeUnitModal(event)" data-close-modal class="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||||
|
<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"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div id="modalContent" class="p-6">
|
||||||
|
<!-- Content will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||||
|
<button id="modalEditBtn" class="w-full h-12 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
||||||
|
Edit Unit
|
||||||
|
</button>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button id="modalDeployBtn" class="h-12 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Deploy/Bench
|
||||||
|
</button>
|
||||||
|
<button id="modalDeleteBtn" class="h-12 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sort-indicator::after {
|
||||||
|
content: '⇅';
|
||||||
|
opacity: 0.3;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.sort-indicator.asc::after {
|
||||||
|
content: '↑';
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.sort-indicator.desc::after {
|
||||||
|
content: '↓';
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Update timestamp
|
||||||
|
const timestampElement = document.getElementById('last-updated');
|
||||||
|
if (timestampElement) {
|
||||||
|
timestampElement.textContent = new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
const timestampMobileElement = document.getElementById('last-updated-mobile');
|
||||||
|
if (timestampMobileElement) {
|
||||||
|
timestampMobileElement.textContent = new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a lightweight status map around for the mobile modal
|
||||||
|
const rosterUnits = {{ units | tojson }};
|
||||||
|
window.rosterStatusMap = rosterUnits.reduce((acc, unit) => {
|
||||||
|
acc[unit.id] = {
|
||||||
|
status: unit.status || 'Unknown',
|
||||||
|
age: unit.age || 'N/A',
|
||||||
|
last: unit.last_seen || 'Never'
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Sorting state
|
||||||
|
let currentSort = { column: null, direction: 'asc' };
|
||||||
|
|
||||||
|
function sortTable(column) {
|
||||||
|
const tbody = document.getElementById('roster-tbody');
|
||||||
|
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||||
|
|
||||||
|
// Determine sort direction
|
||||||
|
if (currentSort.column === column) {
|
||||||
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort.column = column;
|
||||||
|
currentSort.direction = 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort rows
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
let aVal = a.getAttribute(`data-${column}`) || '';
|
||||||
|
let bVal = b.getAttribute(`data-${column}`) || '';
|
||||||
|
|
||||||
|
// Special handling for different column types
|
||||||
|
if (column === 'age') {
|
||||||
|
// Parse age strings like "2h 15m" or "45m" or "3d 5h"
|
||||||
|
aVal = parseAge(aVal);
|
||||||
|
bVal = parseAge(bVal);
|
||||||
|
} else if (column === 'status') {
|
||||||
|
// Sort by status priority: Missing > Pending > OK
|
||||||
|
const statusOrder = { 'Missing': 0, 'Pending': 1, 'OK': 2, '': 3 };
|
||||||
|
aVal = statusOrder[aVal] !== undefined ? statusOrder[aVal] : 999;
|
||||||
|
bVal = statusOrder[bVal] !== undefined ? statusOrder[bVal] : 999;
|
||||||
|
} else if (column === 'last_seen') {
|
||||||
|
// Sort by date
|
||||||
|
aVal = new Date(aVal).getTime() || 0;
|
||||||
|
bVal = new Date(bVal).getTime() || 0;
|
||||||
|
} else {
|
||||||
|
// String comparison (case-insensitive)
|
||||||
|
aVal = aVal.toLowerCase();
|
||||||
|
bVal = bVal.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
||||||
|
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-append rows in sorted order
|
||||||
|
rows.forEach(row => tbody.appendChild(row));
|
||||||
|
|
||||||
|
// Update sort indicators
|
||||||
|
updateSortIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAge(ageStr) {
|
||||||
|
// Parse age strings like "2h 15m", "45m", "3d 5h", "2w 3d"
|
||||||
|
if (!ageStr) return 0;
|
||||||
|
|
||||||
|
let totalMinutes = 0;
|
||||||
|
const weeks = ageStr.match(/(\d+)w/);
|
||||||
|
const days = ageStr.match(/(\d+)d/);
|
||||||
|
const hours = ageStr.match(/(\d+)h/);
|
||||||
|
const minutes = ageStr.match(/(\d+)m/);
|
||||||
|
|
||||||
|
if (weeks) totalMinutes += parseInt(weeks[1]) * 7 * 24 * 60;
|
||||||
|
if (days) totalMinutes += parseInt(days[1]) * 24 * 60;
|
||||||
|
if (hours) totalMinutes += parseInt(hours[1]) * 60;
|
||||||
|
if (minutes) totalMinutes += parseInt(minutes[1]);
|
||||||
|
|
||||||
|
return totalMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortIndicators() {
|
||||||
|
// Clear all indicators
|
||||||
|
document.querySelectorAll('.sort-indicator').forEach(indicator => {
|
||||||
|
indicator.className = 'sort-indicator';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set current indicator
|
||||||
|
if (currentSort.column) {
|
||||||
|
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
||||||
|
if (indicator) {
|
||||||
|
indicator.className = `sort-indicator ${currentSort.direction}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
56
app/ui/templates/partials/seismo_stats.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<!-- Total Seismographs -->
|
||||||
|
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Total Seismographs</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ total }}</p>
|
||||||
|
</div>
|
||||||
|
<svg class="w-12 h-12 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deployed -->
|
||||||
|
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Deployed</p>
|
||||||
|
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">{{ deployed }}</p>
|
||||||
|
</div>
|
||||||
|
<svg class="w-12 h-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Benched -->
|
||||||
|
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
|
||||||
|
</div>
|
||||||
|
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- With Modem -->
|
||||||
|
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">With Modem</p>
|
||||||
|
<p class="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-2">{{ with_modem }}<span class="text-base text-gray-500">/ {{ deployed }}</span></p>
|
||||||
|
{% if without_modem > 0 %}
|
||||||
|
<p class="text-xs text-orange-600 dark:text-orange-400 mt-1">{{ without_modem }} without modem</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<svg class="w-12 h-12 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
97
app/ui/templates/partials/seismo_unit_list.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{% if units %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Unit ID</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Modem</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Notes</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for unit in units %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Deployed
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Benched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.deployed_with_modem_id }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
{% if unit.address %}
|
||||||
|
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||||
|
{% elif unit.coordinates %}
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
||||||
|
{% if unit.note %}
|
||||||
|
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
View Details →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if search %}
|
||||||
|
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Found {{ units|length }} seismograph(s) matching "{{ search }}"
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No seismographs found</h3>
|
||||||
|
{% if search %}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No seismographs match "{{ search }}"</p>
|
||||||
|
<button onclick="document.getElementById('seismo-search').value = ''; htmx.trigger('#seismo-search', 'keyup');"
|
||||||
|
class="mt-3 text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
Clear search
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a seismograph unit from the roster page.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
288
app/ui/templates/partials/slm_config_form.html
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<form id="slm-config-form"
|
||||||
|
hx-post="/api/slm-dashboard/config/{{ unit.id }}"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="handleConfigSave(event)">
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Unit: {{ unit.id }}</h4>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Configure measurement parameters for this sound level meter</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model & Serial -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
|
||||||
|
<select name="slm_model" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select model...</option>
|
||||||
|
<option value="NL-43" {% if unit.slm_model == 'NL-43' %}selected{% endif %}>NL-43</option>
|
||||||
|
<option value="NL-53" {% if unit.slm_model == 'NL-53' %}selected{% endif %}>NL-53</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
||||||
|
<input type="text" name="slm_serial_number" value="{{ unit.slm_serial_number or '' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
placeholder="e.g., SN123456">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Frequency & Time Weighting -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
||||||
|
<select name="slm_frequency_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="A" {% if unit.slm_frequency_weighting == 'A' %}selected{% endif %}>A-weighting</option>
|
||||||
|
<option value="C" {% if unit.slm_frequency_weighting == 'C' %}selected{% endif %}>C-weighting</option>
|
||||||
|
<option value="Z" {% if unit.slm_frequency_weighting == 'Z' %}selected{% endif %}>Z-weighting (Linear)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
||||||
|
<select name="slm_time_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="Fast" {% if unit.slm_time_weighting == 'Fast' %}selected{% endif %}>Fast (125ms)</option>
|
||||||
|
<option value="Slow" {% if unit.slm_time_weighting == 'Slow' %}selected{% endif %}>Slow (1s)</option>
|
||||||
|
<option value="Impulse" {% if unit.slm_time_weighting == 'Impulse' %}selected{% endif %}>Impulse</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Measurement Range -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
||||||
|
<select name="slm_measurement_range" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select range...</option>
|
||||||
|
<option value="30-130" {% if unit.slm_measurement_range == '30-130' %}selected{% endif %}>30-130 dB</option>
|
||||||
|
<option value="40-140" {% if unit.slm_measurement_range == '40-140' %}selected{% endif %}>40-140 dB</option>
|
||||||
|
<option value="50-140" {% if unit.slm_measurement_range == '50-140' %}selected{% endif %}>50-140 dB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Configuration -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-6 mb-4">
|
||||||
|
<h5 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Network Configuration</h5>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned Modem</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select name="deployed_with_modem_id" id="config-modem-select" class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">No modem (direct connection)</option>
|
||||||
|
<!-- Options loaded via JavaScript -->
|
||||||
|
</select>
|
||||||
|
<button type="button" id="test-modem-btn" onclick="testModemConnection()"
|
||||||
|
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors {% if not unit.deployed_with_modem_id %}opacity-50 cursor-not-allowed{% endif %}"
|
||||||
|
{% if not unit.deployed_with_modem_id %}disabled{% endif %}
|
||||||
|
title="Ping modem to test connectivity">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select a modem for network access, or leave blank for direct IP connection</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Port Configuration (always visible) -->
|
||||||
|
<div class="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||||
|
<input type="number" name="slm_tcp_port" value="{{ unit.slm_tcp_port or '2255' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||||
|
placeholder="2255">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Control port</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
|
||||||
|
<input type="number" name="slm_ftp_port" value="{{ unit.slm_ftp_port or '21' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||||
|
placeholder="21">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Data transfer</p>
|
||||||
|
</div>
|
||||||
|
<div id="direct-ip-field" class="{% if unit.deployed_with_modem_id %}hidden{% endif %}">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Direct IP</label>
|
||||||
|
<input type="text" name="slm_host" value="{{ unit.slm_host or '' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||||
|
placeholder="192.168.1.100">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">If no modem</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
|
<button type="button" onclick="closeConfigModal()"
|
||||||
|
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="testSLMConnection('{{ unit.id }}')"
|
||||||
|
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors">
|
||||||
|
Test SLM Connection
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 text-white bg-seismo-orange hover:bg-seismo-burgundy rounded-lg transition-colors">
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Load modems list for dropdown
|
||||||
|
async function loadModemsForConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/roster/modems');
|
||||||
|
const modems = await response.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('config-modem-select');
|
||||||
|
const currentValue = '{{ unit.deployed_with_modem_id or "" }}';
|
||||||
|
|
||||||
|
// Keep the "No modem" option
|
||||||
|
modems.forEach(modem => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = modem.id;
|
||||||
|
const ipText = modem.ip_address ? ' (' + modem.ip_address + ')' : '';
|
||||||
|
option.textContent = modem.id + ipText;
|
||||||
|
if (modem.id === currentValue) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load modems:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle direct IP field and test modem button based on modem selection
|
||||||
|
document.getElementById('config-modem-select')?.addEventListener('change', function() {
|
||||||
|
const directIpField = document.getElementById('direct-ip-field');
|
||||||
|
const testModemBtn = document.getElementById('test-modem-btn');
|
||||||
|
|
||||||
|
if (this.value === '') {
|
||||||
|
directIpField.classList.remove('hidden');
|
||||||
|
testModemBtn.disabled = true;
|
||||||
|
testModemBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||||
|
} else {
|
||||||
|
directIpField.classList.add('hidden');
|
||||||
|
testModemBtn.disabled = false;
|
||||||
|
testModemBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle save response
|
||||||
|
function handleConfigSave(event) {
|
||||||
|
if (event.detail.successful) {
|
||||||
|
// Show success message
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Configuration saved successfully!';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
closeConfigModal();
|
||||||
|
// Refresh the unit list
|
||||||
|
htmx.trigger('#slm-list', 'load');
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
// Show error message
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Failed to save configuration';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection to modem (health ping)
|
||||||
|
async function testModemConnection() {
|
||||||
|
const modemSelect = document.getElementById('config-modem-select');
|
||||||
|
const modemId = modemSelect.value;
|
||||||
|
|
||||||
|
if (!modemId) {
|
||||||
|
alert('Please select a modem first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Pinging modem...';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/slm-dashboard/test-modem/' + modemId);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
toast.remove();
|
||||||
|
|
||||||
|
const resultToast = document.createElement('div');
|
||||||
|
if (response.ok && data.status === 'success') {
|
||||||
|
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
const ipAddr = data.ip_address || modemId;
|
||||||
|
const respTime = data.response_time || 'N/A';
|
||||||
|
resultToast.innerHTML = '✓ Modem responding!<br><span class="text-xs">' + ipAddr + ' - ' + respTime + 'ms</span>';
|
||||||
|
} else {
|
||||||
|
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
resultToast.textContent = '⚠ Modem not responding: ' + (data.detail || 'Unknown error');
|
||||||
|
}
|
||||||
|
document.body.appendChild(resultToast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resultToast.remove();
|
||||||
|
}, 4000);
|
||||||
|
} catch (error) {
|
||||||
|
toast.remove();
|
||||||
|
const errorToast = document.createElement('div');
|
||||||
|
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
errorToast.textContent = '✗ Failed to ping modem: ' + error.message;
|
||||||
|
document.body.appendChild(errorToast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
errorToast.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection to SLM
|
||||||
|
async function testSLMConnection(unitId) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Testing SLM connection...';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/slmm/' + unitId + '/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
toast.remove();
|
||||||
|
|
||||||
|
const resultToast = document.createElement('div');
|
||||||
|
if (response.ok && data.status === 'online') {
|
||||||
|
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
resultToast.textContent = '✓ SLM connection successful! ' + (data.model || 'SLM') + ' responding';
|
||||||
|
} else {
|
||||||
|
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
resultToast.textContent = '⚠ SLM connection failed or device offline';
|
||||||
|
}
|
||||||
|
document.body.appendChild(resultToast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resultToast.remove();
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
toast.remove();
|
||||||
|
const errorToast = document.createElement('div');
|
||||||
|
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
errorToast.textContent = '✗ SLM connection test failed: ' + error.message;
|
||||||
|
document.body.appendChild(errorToast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
errorToast.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load modems on page load
|
||||||
|
loadModemsForConfig();
|
||||||
|
</script>
|
||||||
215
app/ui/templates/partials/slm_config_form_old.html
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<form id="slm-config-form"
|
||||||
|
hx-post="/api/slm-dashboard/config/{{ unit.id }}"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="handleConfigSave(event)">
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Unit: {{ unit.id }}</h4>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Configure measurement parameters for this sound level meter</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model & Serial -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
|
||||||
|
<select name="slm_model" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select model...</option>
|
||||||
|
<option value="NL-43" {% if unit.slm_model == 'NL-43' %}selected{% endif %}>NL-43</option>
|
||||||
|
<option value="NL-53" {% if unit.slm_model == 'NL-53' %}selected{% endif %}>NL-53</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
||||||
|
<input type="text" name="slm_serial_number" value="{{ unit.slm_serial_number or '' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
placeholder="e.g., SN123456">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Frequency & Time Weighting -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
||||||
|
<select name="slm_frequency_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="A" {% if unit.slm_frequency_weighting == 'A' %}selected{% endif %}>A-weighting</option>
|
||||||
|
<option value="C" {% if unit.slm_frequency_weighting == 'C' %}selected{% endif %}>C-weighting</option>
|
||||||
|
<option value="Z" {% if unit.slm_frequency_weighting == 'Z' %}selected{% endif %}>Z-weighting (Linear)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
||||||
|
<select name="slm_time_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="Fast" {% if unit.slm_time_weighting == 'Fast' %}selected{% endif %}>Fast (125ms)</option>
|
||||||
|
<option value="Slow" {% if unit.slm_time_weighting == 'Slow' %}selected{% endif %}>Slow (1s)</option>
|
||||||
|
<option value="Impulse" {% if unit.slm_time_weighting == 'Impulse' %}selected{% endif %}>Impulse</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Measurement Range -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
||||||
|
<select name="slm_measurement_range" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select range...</option>
|
||||||
|
<option value="30-130" {% if unit.slm_measurement_range == '30-130' %}selected{% endif %}>30-130 dB</option>
|
||||||
|
<option value="40-140" {% if unit.slm_measurement_range == '40-140' %}selected{% endif %}>40-140 dB</option>
|
||||||
|
<option value="50-140" {% if unit.slm_measurement_range == '50-140' %}selected{% endif %}>50-140 dB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Configuration -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-6 mb-4">
|
||||||
|
<h5 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Network Configuration</h5>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned Modem</label>
|
||||||
|
<select name="deployed_with_modem_id" id="config-modem-select" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">No modem (direct connection)</option>
|
||||||
|
<!-- Options loaded via JavaScript -->
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select a modem for network access, or leave blank for direct IP connection</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legacy direct connection (shown only if no modem selected) -->
|
||||||
|
<div id="direct-connection-fields" class="{% if unit.deployed_with_modem_id %}hidden{% endif %}">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Direct IP Address</label>
|
||||||
|
<input type="text" name="slm_host" value="{{ unit.slm_host or '' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||||
|
placeholder="192.168.1.100">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||||
|
<input type="number" name="slm_tcp_port" value="{{ unit.slm_tcp_port or '' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||||
|
placeholder="502">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
|
<button type="button" onclick="closeConfigModal()"
|
||||||
|
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="testConnection('{{ unit.id }}')"
|
||||||
|
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors">
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 text-white bg-seismo-orange hover:bg-seismo-burgundy rounded-lg transition-colors">
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Load modems list for dropdown
|
||||||
|
async function loadModemsForConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/roster/modems');
|
||||||
|
const modems = await response.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('config-modem-select');
|
||||||
|
const currentValue = '{{ unit.deployed_with_modem_id or "" }}';
|
||||||
|
|
||||||
|
// Keep the "No modem" option
|
||||||
|
modems.forEach(modem => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = modem.id;
|
||||||
|
option.textContent = `${modem.id}${modem.ip_address ? ' (' + modem.ip_address + ')' : ''}`;
|
||||||
|
if (modem.id === currentValue) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load modems:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle direct connection fields based on modem selection
|
||||||
|
document.getElementById('config-modem-select')?.addEventListener('change', function() {
|
||||||
|
const directFields = document.getElementById('direct-connection-fields');
|
||||||
|
if (this.value === '') {
|
||||||
|
directFields.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
directFields.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle save response
|
||||||
|
function handleConfigSave(event) {
|
||||||
|
if (event.detail.successful) {
|
||||||
|
// Show success message
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Configuration saved successfully!';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
closeConfigModal();
|
||||||
|
// Refresh the unit list
|
||||||
|
htmx.trigger('#slm-list', 'load');
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
// Show error message
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Failed to save configuration';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection to SLM
|
||||||
|
async function testConnection(unitId) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Testing connection...';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/status`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
toast.remove();
|
||||||
|
|
||||||
|
const resultToast = document.createElement('div');
|
||||||
|
if (response.ok && data.status === 'online') {
|
||||||
|
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
resultToast.textContent = `✓ Connection successful! ${data.model || 'SLM'} responding`;
|
||||||
|
} else {
|
||||||
|
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
resultToast.textContent = `Connection failed or device offline`;
|
||||||
|
}
|
||||||
|
document.body.appendChild(resultToast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resultToast.remove();
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
toast.remove();
|
||||||
|
const errorToast = document.createElement('div');
|
||||||
|
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
errorToast.textContent = '✗ Connection test failed';
|
||||||
|
document.body.appendChild(errorToast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
errorToast.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load modems on page load
|
||||||
|
loadModemsForConfig();
|
||||||
|
</script>
|
||||||
105
app/ui/templates/partials/slm_controls.html
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<div class="mb-6 p-4 rounded-lg {% if is_measuring %}bg-green-50 dark:bg-green-900/20{% else %}bg-gray-50 dark:bg-gray-900{% endif %}">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Measurement Status</div>
|
||||||
|
<div class="text-2xl font-bold {% if is_measuring %}text-green-600 dark:text-green-400{% else %}text-gray-600 dark:text-gray-400{% endif %}">
|
||||||
|
{% if measurement_state %}
|
||||||
|
{{ measurement_state }}
|
||||||
|
{% if is_measuring %}
|
||||||
|
<span class="inline-block w-3 h-3 bg-green-500 rounded-full ml-2 animate-pulse"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
Unknown
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Battery</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{{ battery_level or '--' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Buttons -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/start"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||||
|
class="px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2
|
||||||
|
{% if is_measuring %}opacity-50 cursor-not-allowed{% endif %}"
|
||||||
|
{% if is_measuring %}disabled{% endif %}>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/stop"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||||
|
class="px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center justify-center gap-2
|
||||||
|
{% if not is_measuring %}opacity-50 cursor-not-allowed{% endif %}"
|
||||||
|
{% if not is_measuring %}disabled{% endif %}>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||||||
|
</svg>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/pause"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||||
|
class="px-4 py-3 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/reset"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-confirm="Are you sure you want to reset the measurement data?"
|
||||||
|
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||||
|
class="px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button hx-get="/api/slmm/{{ unit_id }}/live"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-indicator="#live-spinner"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<svg id="live-spinner" class="htmx-indicator w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
Get Live Data
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/store"
|
||||||
|
hx-swap="none"
|
||||||
|
class="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
|
||||||
|
</svg>
|
||||||
|
Store Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="slm-controls" hx-get="/slm/partials/{{ unit_id }}/controls" hx-trigger="refresh" hx-swap="outerHTML"></div>
|
||||||
1462
app/ui/templates/partials/slm_live_view.html
Normal file
438
app/ui/templates/partials/slm_live_view.html.backup
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
<!-- Live View Panel for {{ unit.id }} -->
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<!-- 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>
|
||||||
|
{% if modem %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
via Modem: {{ modem.id }}{% if modem_ip %} ({{ modem_ip }}){% endif %}
|
||||||
|
</p>
|
||||||
|
{% elif modem_ip %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Direct: {{ modem_ip }}
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs text-red-500 dark:text-red-400 mt-1">
|
||||||
|
⚠️ No modem assigned or IP configured
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Measurement Status Badge -->
|
||||||
|
<div>
|
||||||
|
{% if is_measuring %}
|
||||||
|
<span class="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">
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||||
|
Measuring
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium">
|
||||||
|
Stopped
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Buttons -->
|
||||||
|
<div class="flex gap-2 mb-6">
|
||||||
|
<button onclick="controlUnit('{{ unit.id }}', 'start')"
|
||||||
|
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg flex items-center">
|
||||||
|
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="controlUnit('{{ unit.id }}', 'pause')"
|
||||||
|
class="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg flex items-center">
|
||||||
|
<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="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="controlUnit('{{ unit.id }}', 'stop')"
|
||||||
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center">
|
||||||
|
<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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||||||
|
</svg>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="controlUnit('{{ unit.id }}', 'reset')"
|
||||||
|
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg flex items-center">
|
||||||
|
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="start-stream-btn" onclick="initLiveDataStream('{{ unit.id }}')"
|
||||||
|
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center ml-auto">
|
||||||
|
<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="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
Start Live Stream
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="stop-stream-btn" onclick="stopLiveDataStream()" style="display: none;"
|
||||||
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center ml-auto">
|
||||||
|
<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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||||||
|
</svg>
|
||||||
|
Stop Live Stream
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Metrics -->
|
||||||
|
<div class="grid grid-cols-5 gap-4 mb-6">
|
||||||
|
<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="live-lp" class="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{% if current_status and current_status.lp %}{{ current_status.lp }}{% else %}--{% endif %}
|
||||||
|
</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="live-leq" class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{% if current_status and current_status.leq %}{{ current_status.leq }}{% else %}--{% endif %}
|
||||||
|
</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="live-lmax" class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||||
|
{% if current_status and current_status.lmax %}{{ current_status.lmax }}{% else %}--{% endif %}
|
||||||
|
</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="live-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
|
{% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
|
||||||
|
<p id="live-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||||
|
{% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Chart -->
|
||||||
|
<div class="flex-1 bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4" style="min-height: 400px;">
|
||||||
|
<canvas id="liveChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Info -->
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Battery:</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||||
|
{% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}--{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Power:</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||||
|
{% if current_status and current_status.power_source %}{{ current_status.power_source }}{% else %}--{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Weighting:</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||||
|
{% if unit.slm_frequency_weighting %}{{ unit.slm_frequency_weighting }}{% else %}--{% endif %} /
|
||||||
|
{% if unit.slm_time_weighting %}{{ unit.slm_time_weighting }}{% else %}--{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">SD Remaining:</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||||
|
{% if current_status and current_status.sd_remaining_mb %}{{ current_status.sd_remaining_mb }} MB{% else %}--{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
// Initialize Chart.js for live data visualization
|
||||||
|
function initializeChart() {
|
||||||
|
// Wait for Chart.js to load
|
||||||
|
if (typeof Chart === 'undefined') {
|
||||||
|
console.log('Waiting for Chart.js to load...');
|
||||||
|
setTimeout(initializeChart, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Chart.js loaded, version:', Chart.version);
|
||||||
|
|
||||||
|
const canvas = document.getElementById('liveChart');
|
||||||
|
if (!canvas) {
|
||||||
|
console.error('Chart canvas not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Canvas found:', canvas);
|
||||||
|
|
||||||
|
// Destroy existing chart if it exists
|
||||||
|
if (window.liveChart && typeof window.liveChart.destroy === 'function') {
|
||||||
|
console.log('Destroying existing chart');
|
||||||
|
window.liveChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
console.log('Creating new chart...');
|
||||||
|
|
||||||
|
// Dark mode detection
|
||||||
|
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||||
|
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||||
|
const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
|
||||||
|
|
||||||
|
window.liveChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Lp (Instantaneous)',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgb(59, 130, 246)',
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Leq (Equivalent)',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgb(34, 197, 94)',
|
||||||
|
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'index'
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: true,
|
||||||
|
grid: {
|
||||||
|
color: gridColor
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: textColor,
|
||||||
|
maxTicksLimit: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
display: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Sound Level (dB)',
|
||||||
|
color: textColor
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: gridColor
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: textColor
|
||||||
|
},
|
||||||
|
min: 30,
|
||||||
|
max: 130
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Chart created successfully:', window.liveChart);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize chart when DOM is ready
|
||||||
|
console.log('Executing initializeChart...');
|
||||||
|
initializeChart();
|
||||||
|
|
||||||
|
// WebSocket management (use global scope to avoid redeclaration)
|
||||||
|
if (typeof window.currentWebSocket === 'undefined') {
|
||||||
|
window.currentWebSocket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLiveDataStream(unitId) {
|
||||||
|
// Close existing connection if any
|
||||||
|
if (window.currentWebSocket) {
|
||||||
|
window.currentWebSocket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset chart data
|
||||||
|
if (window.chartData) {
|
||||||
|
window.chartData.timestamps = [];
|
||||||
|
window.chartData.lp = [];
|
||||||
|
window.chartData.leq = [];
|
||||||
|
}
|
||||||
|
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
|
||||||
|
window.liveChart.data.labels = [];
|
||||||
|
window.liveChart.data.datasets[0].data = [];
|
||||||
|
window.liveChart.data.datasets[1].data = [];
|
||||||
|
window.liveChart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket URL for SLMM backend via proxy
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
||||||
|
|
||||||
|
window.currentWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
window.currentWebSocket.onopen = function() {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
// Toggle button visibility
|
||||||
|
const startBtn = document.getElementById('start-stream-btn');
|
||||||
|
const stopBtn = document.getElementById('stop-stream-btn');
|
||||||
|
if (startBtn) startBtn.style.display = 'none';
|
||||||
|
if (stopBtn) stopBtn.style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.currentWebSocket.onmessage = function(event) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log('WebSocket data received:', data);
|
||||||
|
updateLiveMetrics(data);
|
||||||
|
updateLiveChart(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.currentWebSocket.onerror = function(error) {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.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 (window.currentWebSocket) {
|
||||||
|
window.currentWebSocket.close();
|
||||||
|
window.currentWebSocket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metrics display
|
||||||
|
function updateLiveMetrics(data) {
|
||||||
|
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 || '--';
|
||||||
|
}
|
||||||
|
if (document.getElementById('live-lpeak')) {
|
||||||
|
document.getElementById('live-lpeak').textContent = data.lpeak || '--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart data storage (use global scope to avoid redeclaration)
|
||||||
|
if (typeof window.chartData === 'undefined') {
|
||||||
|
window.chartData = {
|
||||||
|
timestamps: [],
|
||||||
|
lp: [],
|
||||||
|
leq: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update live chart
|
||||||
|
function updateLiveChart(data) {
|
||||||
|
const now = new Date();
|
||||||
|
window.chartData.timestamps.push(now.toLocaleTimeString());
|
||||||
|
window.chartData.lp.push(parseFloat(data.lp || 0));
|
||||||
|
window.chartData.leq.push(parseFloat(data.leq || 0));
|
||||||
|
|
||||||
|
// Keep only last 60 data points
|
||||||
|
if (window.chartData.timestamps.length > 60) {
|
||||||
|
window.chartData.timestamps.shift();
|
||||||
|
window.chartData.lp.shift();
|
||||||
|
window.chartData.leq.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update chart if available
|
||||||
|
if (window.liveChart) {
|
||||||
|
window.liveChart.data.labels = window.chartData.timestamps;
|
||||||
|
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
||||||
|
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
||||||
|
window.liveChart.update('none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control function
|
||||||
|
async function controlUnit(unitId, action) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slm-dashboard/control/${unitId}/${action}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
// Reload the live view to update status
|
||||||
|
setTimeout(() => {
|
||||||
|
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
||||||
|
target: '#live-view-panel',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Failed to control unit: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on page unload
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
if (window.currentWebSocket) {
|
||||||
|
window.currentWebSocket.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
8
app/ui/templates/partials/slm_live_view_error.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- Error State for Live View -->
|
||||||
|
<div class="flex flex-col items-center justify-center h-[600px] text-red-500 dark:text-red-400">
|
||||||
|
<svg class="w-24 h-24 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg font-medium">Error Loading Unit</p>
|
||||||
|
<p class="text-sm mt-2 text-gray-600 dark:text-gray-400">{{ error }}</p>
|
||||||
|
</div>
|
||||||
61
app/ui/templates/partials/slm_stats.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<!-- Summary stat cards -->
|
||||||
|
<!-- Total Units Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Total Units</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ total_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deployed Units Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Deployed</p>
|
||||||
|
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">{{ deployed_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Now Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Active Now</p>
|
||||||
|
<p class="text-3xl font-bold text-seismo-orange mt-1">{{ active_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-orange-100 dark:bg-orange-900/30 p-3 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Checked in last hour</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Benched Units Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Benched</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-500 dark:text-gray-400 mt-1">{{ benched_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 p-3 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
73
app/ui/templates/partials/slm_unit_list.html
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<!-- SLM Unit List -->
|
||||||
|
{% if units %}
|
||||||
|
{% for unit in units %}
|
||||||
|
<div class="slm-unit-item bg-gray-100 dark:bg-gray-700 rounded-lg p-4 transition-colors relative group">
|
||||||
|
<!-- Configure button (appears on hover) -->
|
||||||
|
<button onclick="event.stopPropagation(); openConfigModal('{{ unit.id }}');"
|
||||||
|
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full p-1.5 hover:bg-gray-200 dark:hover:bg-gray-600 z-10"
|
||||||
|
title="Configure {{ unit.id }}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="cursor-pointer" onclick="selectUnit('{{ unit.id }}')">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="font-semibold">{{ unit.id }}</span>
|
||||||
|
<!-- Status indicator: green=active, yellow=recent, red=old, gray=never -->
|
||||||
|
{% if unit.slm_last_check %}
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full" title="Active"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 bg-gray-400 rounded-full" title="No check-in"></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm space-y-1">
|
||||||
|
{% if unit.slm_model %}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ unit.slm_model }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unit.address %}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="truncate">{{ unit.address }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<div class="flex items-center font-mono text-xs">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
{{ unit.deployed_with_modem_id }}
|
||||||
|
</div>
|
||||||
|
{% elif unit.slm_host %}
|
||||||
|
<div class="flex items-center font-mono text-xs">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
|
||||||
|
</svg>
|
||||||
|
{{ unit.slm_host }}{% if unit.slm_tcp_port %}:{{ unit.slm_tcp_port }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
<p>No sound level meters found</p>
|
||||||
|
<p class="text-sm mt-1">Add units from the Fleet Roster</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
61
app/ui/templates/partials/unknown_emitters.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{% if unknown_units %}
|
||||||
|
<div class="mb-6 rounded-xl shadow-lg bg-yellow-50 dark:bg-yellow-900/20 border-2 border-yellow-400 dark:border-yellow-600 p-6">
|
||||||
|
<div class="flex items-start gap-3 mb-4">
|
||||||
|
<svg class="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="text-xl font-bold text-yellow-900 dark:text-yellow-200">Unknown Emitters Detected</h2>
|
||||||
|
<p class="text-sm text-yellow-800 dark:text-yellow-300 mt-1">
|
||||||
|
{{ unknown_units|length }} unit(s) are reporting but not in the roster. Add them to track them properly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for unit in unknown_units %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 flex items-center justify-between border border-yellow-300 dark:border-yellow-700">
|
||||||
|
<div class="flex items-center gap-4 flex-1">
|
||||||
|
<div class="font-mono font-bold text-lg text-gray-900 dark:text-white">
|
||||||
|
{{ unit.id }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="px-2 py-1 rounded-full
|
||||||
|
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||||
|
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||||
|
{% else %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300{% endif %}">
|
||||||
|
{{ unit.status }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">
|
||||||
|
Last seen: {{ unit.age }}
|
||||||
|
</span>
|
||||||
|
{% if unit.fname %}
|
||||||
|
<span class="text-gray-500 dark:text-gray-500 text-xs truncate max-w-xs">
|
||||||
|
{{ unit.fname }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick="addUnknownUnit('{{ unit.id }}')"
|
||||||
|
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center gap-2 transition-colors whitespace-nowrap">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||||
|
</svg>
|
||||||
|
Add to Roster
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick="ignoreUnknownUnit('{{ unit.id }}')"
|
||||||
|
class="px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-lg flex items-center gap-2 transition-colors whitespace-nowrap">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||||
|
</svg>
|
||||||
|
Ignore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
1137
app/ui/templates/roster.html
Normal file
76
app/ui/templates/seismographs.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Seismographs - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Seismographs</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage and monitor seismograph units</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-refresh stats every 30 seconds -->
|
||||||
|
<div hx-get="/api/seismo-dashboard/stats"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="mb-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Loading...</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seismograph List -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
|
||||||
|
|
||||||
|
<!-- Search Box -->
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="seismo-search"
|
||||||
|
placeholder="Search seismographs..."
|
||||||
|
class="w-full sm:w-64 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
hx-get="/api/seismo-dashboard/units"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#seismo-units-list"
|
||||||
|
hx-include="[name='search']"
|
||||||
|
name="search"
|
||||||
|
/>
|
||||||
|
<svg class="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Units List (loaded via HTMX) -->
|
||||||
|
<div id="seismo-units-list"
|
||||||
|
hx-get="/api/seismo-dashboard/units"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Loading seismographs...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Clear search input on escape key
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const searchInput = document.getElementById('seismo-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this.value = '';
|
||||||
|
htmx.trigger(this, 'keyup');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
1376
app/ui/templates/settings.html
Normal file
195
app/ui/templates/slm_detail.html
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ unit_id }} - Sound Level Meter{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/roster" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
Back to Roster
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<svg class="w-8 h-8 mr-3 text-seismo-orange" 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">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
{{ unit_id }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{{ unit.slm_model or 'NL-43' }} Sound Level Meter
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium
|
||||||
|
{% if unit.deployed %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||||
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||||
|
{% if unit.deployed %}Deployed{% else %}Benched{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Panel -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Control Panel</h2>
|
||||||
|
<div hx-get="/slm/partials/{{ unit_id }}/controls"
|
||||||
|
hx-trigger="load, every 5s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading controls...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Real-time Data Stream -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Real-time Measurements</h2>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div id="slm-stream-container">
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<button onclick="startStream()"
|
||||||
|
id="stream-start-btn"
|
||||||
|
class="px-6 py-3 bg-seismo-orange text-white rounded-lg hover:bg-seismo-orange-dark transition-colors">
|
||||||
|
Start Real-time Stream
|
||||||
|
</button>
|
||||||
|
<p class="text-sm text-gray-500 mt-2">Click to begin streaming live measurement data</p>
|
||||||
|
</div>
|
||||||
|
<div id="stream-data" class="hidden">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</div>
|
||||||
|
<div id="stream-lp" class="text-3xl font-bold text-gray-900 dark:text-white">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</div>
|
||||||
|
<div id="stream-leq" class="text-3xl font-bold text-blue-600 dark:text-blue-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmax</div>
|
||||||
|
<div id="stream-lmax" class="text-3xl font-bold text-red-600 dark:text-red-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmin</div>
|
||||||
|
<div id="stream-lmin" class="text-3xl font-bold text-green-600 dark:text-green-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||||
|
Streaming
|
||||||
|
</div>
|
||||||
|
<button onclick="stopStream()"
|
||||||
|
class="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors">
|
||||||
|
Stop Stream
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Information -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Device Information</h2>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Model</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_model or 'NL-43' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Serial Number</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_serial_number or 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Host</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_host or 'Not configured' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">TCP Port</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_tcp_port or 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Frequency Weighting</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_frequency_weighting or 'A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Time Weighting</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_time_weighting or 'F (Fast)' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Location</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.address or unit.location or 'Not specified' }}</div>
|
||||||
|
</div>
|
||||||
|
{% if unit.note %}
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ unit.note }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
function startStream() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/api/slmm/{{ unit_id }}/stream`;
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
document.getElementById('stream-start-btn').classList.add('hidden');
|
||||||
|
document.getElementById('stream-data').classList.remove('hidden');
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
console.error('Stream error:', data.error);
|
||||||
|
stopStream();
|
||||||
|
alert('Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update values
|
||||||
|
document.getElementById('stream-lp').textContent = data.lp || '--';
|
||||||
|
document.getElementById('stream-leq').textContent = data.leq || '--';
|
||||||
|
document.getElementById('stream-lmax').textContent = data.lmax || '--';
|
||||||
|
document.getElementById('stream-lmin').textContent = data.lmin || '--';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
stopStream();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStream() {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
document.getElementById('stream-start-btn').classList.remove('hidden');
|
||||||
|
document.getElementById('stream-data').classList.add('hidden');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
249
app/ui/templates/sound_level_meters.html
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Sound Level Meters - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sound Level Meters</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Monitor and manage sound level measurement devices</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
|
||||||
|
hx-get="/api/slm-dashboard/stats"
|
||||||
|
hx-trigger="load, every 10s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<!-- Stats will be loaded here -->
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- SLM List -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Units</h2>
|
||||||
|
|
||||||
|
<!-- Search/Filter -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<input type="text"
|
||||||
|
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"
|
||||||
|
hx-get="/api/slm-dashboard/units"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#slm-list"
|
||||||
|
hx-include="this"
|
||||||
|
name="search">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SLM List -->
|
||||||
|
<div id="slm-list"
|
||||||
|
class="space-y-2 max-h-[600px] overflow-y-auto"
|
||||||
|
hx-get="/api/slm-dashboard/units"
|
||||||
|
hx-trigger="load, every 10s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<!-- Loading skeleton -->
|
||||||
|
<div class="animate-pulse space-y-2">
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live View Panel -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div id="live-view-panel" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<!-- Initial state - no unit selected -->
|
||||||
|
<div class="flex flex-col items-center justify-center h-[600px] text-gray-400 dark:text-gray-500">
|
||||||
|
<svg class="w-24 h-24 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg font-medium">No unit selected</p>
|
||||||
|
<p class="text-sm mt-2">Select a sound level meter from the list to view live data</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Modal -->
|
||||||
|
<div id="config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Configure SLM</h3>
|
||||||
|
<button onclick="closeConfigModal()" 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"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="config-modal-content">
|
||||||
|
<!-- Content loaded via HTMX -->
|
||||||
|
<div class="animate-pulse space-y-4">
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Function to select a unit and load live view
|
||||||
|
function selectUnit(unitId) {
|
||||||
|
// Remove active state from all items
|
||||||
|
document.querySelectorAll('.slm-unit-item').forEach(item => {
|
||||||
|
item.classList.remove('bg-seismo-orange', 'text-white');
|
||||||
|
item.classList.add('bg-gray-100', 'dark:bg-gray-700');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add active state to clicked item
|
||||||
|
event.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700');
|
||||||
|
event.currentTarget.classList.add('bg-seismo-orange', 'text-white');
|
||||||
|
|
||||||
|
// Load live view for this unit
|
||||||
|
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
||||||
|
target: '#live-view-panel',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration modal functions
|
||||||
|
function openConfigModal(unitId) {
|
||||||
|
const modal = document.getElementById('config-modal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Load configuration form via HTMX
|
||||||
|
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
||||||
|
target: '#config-modal-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfigModal() {
|
||||||
|
document.getElementById('config-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
document.getElementById('config-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize WebSocket for selected unit
|
||||||
|
let currentWebSocket = null;
|
||||||
|
|
||||||
|
function initLiveDataStream(unitId) {
|
||||||
|
// Close existing connection if any
|
||||||
|
if (currentWebSocket) {
|
||||||
|
currentWebSocket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket URL for SLMM backend via proxy
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
||||||
|
|
||||||
|
currentWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
currentWebSocket.onopen = function() {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
// Toggle button visibility
|
||||||
|
const startBtn = document.getElementById('start-stream-btn');
|
||||||
|
const stopBtn = document.getElementById('stop-stream-btn');
|
||||||
|
if (startBtn) startBtn.style.display = 'none';
|
||||||
|
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 || '--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on page unload
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
if (currentWebSocket) {
|
||||||
|
currentWebSocket.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
1008
app/ui/templates/unit_detail.html
Normal file
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -23,8 +23,20 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Measurement Status Badge -->
|
<!-- Measurement Status Badge & Timer -->
|
||||||
<div>
|
<div class="flex flex-col items-end gap-2">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Timer Display -->
|
||||||
|
<div id="measurement-timer" class="px-4 py-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" 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 id="timer-display" class="text-xl font-mono font-bold text-blue-600 dark:text-blue-400">00:00:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Badge -->
|
||||||
{% if is_measuring %}
|
{% if is_measuring %}
|
||||||
<span class="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">
|
<span class="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">
|
||||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
<span class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||||
@@ -37,6 +49,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Control Buttons -->
|
<!-- Control Buttons -->
|
||||||
<div class="flex gap-2 mb-6">
|
<div class="flex gap-2 mb-6">
|
||||||
@@ -209,6 +222,171 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Settings -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Device Settings</h3>
|
||||||
|
|
||||||
|
<!-- Clock Sync -->
|
||||||
|
<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-3">
|
||||||
|
<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">Device Clock</span>
|
||||||
|
</div>
|
||||||
|
<button onclick="syncClockToNow()"
|
||||||
|
class="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded flex items-center">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
Sync to Current Time
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Set the device clock manually or sync to your computer's current time. Device must be stopped to change clock.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="datetime-local"
|
||||||
|
id="clock-input"
|
||||||
|
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<button onclick="setDeviceClock()"
|
||||||
|
class="px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded">
|
||||||
|
Set Clock
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="clock-status" class="mt-2 text-xs hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Store Name (Index Number) -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 mt-3">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 text-gray-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Store Name (Index)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span id="store-name-display" class="text-sm font-mono text-gray-600 dark:text-gray-400">----</span>
|
||||||
|
<span id="overwrite-indicator" class="text-xs px-2 py-1 rounded hidden"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Set the file index number (0000-9999) for the next measurement storage. <strong>Requirements:</strong> Device must be <strong>stopped</strong> and <strong>FTP must be disabled</strong> to change this setting.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="overwrite-warning" class="hidden mb-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-500 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-xs font-semibold text-yellow-800 dark:text-yellow-200">Data Already Exists</p>
|
||||||
|
<p class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">Starting a measurement will overwrite existing data at this index. Consider using auto-increment to find an empty slot.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="number"
|
||||||
|
id="store-name-input"
|
||||||
|
min="0"
|
||||||
|
max="9999"
|
||||||
|
placeholder="0000"
|
||||||
|
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<button onclick="setStoreName()"
|
||||||
|
class="px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded">
|
||||||
|
Set Index
|
||||||
|
</button>
|
||||||
|
<button onclick="autoIncrementStoreName()"
|
||||||
|
class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded"
|
||||||
|
title="Find next available index">
|
||||||
|
Auto +
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="store-name-status" class="mt-2 text-xs hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Measurement Data Files -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Measurement Data Files</h3>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="enable-ftp-btn" onclick="enableFTP()" class="px-3 py-1 text-xs bg-green-600 hover:bg-green-700 text-white rounded hidden">
|
||||||
|
Enable FTP
|
||||||
|
</button>
|
||||||
|
<button id="disable-ftp-btn" onclick="disableFTP()" class="px-3 py-1 text-xs bg-red-600 hover:bg-red-700 text-white rounded hidden">
|
||||||
|
Disable FTP
|
||||||
|
</button>
|
||||||
|
<button onclick="refreshFileList()"
|
||||||
|
class="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded flex items-center">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FTP Status Indicator -->
|
||||||
|
<div id="ftp-status" class="mb-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg hidden">
|
||||||
|
<div class="flex items-center text-sm text-yellow-800 dark:text-yellow-400">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<span id="ftp-status-text">FTP server is disabled. Click "Enable FTP" to browse files.</span>
|
||||||
|
</div>
|
||||||
|
<button onclick="enableFTP()"
|
||||||
|
class="mt-2 px-3 py-1 text-xs bg-yellow-600 hover:bg-yellow-700 text-white rounded">
|
||||||
|
Enable FTP
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Browser -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<!-- Current Path -->
|
||||||
|
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700 flex items-center">
|
||||||
|
<svg class="w-4 h-4 text-gray-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
|
||||||
|
</svg>
|
||||||
|
<span id="current-path" class="text-sm text-gray-700 dark:text-gray-300 font-mono">/</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File List -->
|
||||||
|
<div id="file-list" class="divide-y divide-gray-200 dark:divide-gray-700 max-h-96 overflow-y-auto">
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<p>Click "Refresh" to load files from the device</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div id="file-list-loading" class="p-8 text-center hidden">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading files...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div id="file-list-error" class="p-4 bg-red-50 dark:bg-red-900/20 border-t border-red-200 dark:border-red-800 hidden">
|
||||||
|
<div class="flex items-center text-sm text-red-800 dark:text-red-400">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<span id="file-list-error-text">Failed to load files</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
@@ -236,9 +414,14 @@ function initializeChart() {
|
|||||||
if (window.liveChart && typeof window.liveChart.destroy === 'function') {
|
if (window.liveChart && typeof window.liveChart.destroy === 'function') {
|
||||||
console.log('Destroying existing chart');
|
console.log('Destroying existing chart');
|
||||||
window.liveChart.destroy();
|
window.liveChart.destroy();
|
||||||
|
window.liveChart = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
console.error('Failed to get 2D context from canvas');
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log('Creating new chart...');
|
console.log('Creating new chart...');
|
||||||
|
|
||||||
// Dark mode detection
|
// Dark mode detection
|
||||||
@@ -246,6 +429,7 @@ function initializeChart() {
|
|||||||
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||||
const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
|
const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
|
||||||
|
|
||||||
|
try {
|
||||||
window.liveChart = new Chart(ctx, {
|
window.liveChart = new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
@@ -318,11 +502,32 @@ function initializeChart() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('Chart created successfully:', window.liveChart);
|
console.log('Chart created successfully:', window.liveChart);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create chart:', error);
|
||||||
|
// Display error message in the chart container
|
||||||
|
const container = canvas.parentElement;
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="flex items-center justify-center h-full text-sm text-red-600 dark:text-red-400">
|
||||||
|
<div class="text-center">
|
||||||
|
<svg class="w-8 h-8 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||||
|
</svg>
|
||||||
|
<p>Chart failed to load</p>
|
||||||
|
<p class="text-xs mt-1">Try refreshing the page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize chart when DOM is ready
|
// Initialize chart when DOM is ready
|
||||||
console.log('Executing initializeChart...');
|
console.log('Executing initializeChart...');
|
||||||
|
// Use a small delay to ensure DOM is fully ready
|
||||||
|
setTimeout(() => {
|
||||||
initializeChart();
|
initializeChart();
|
||||||
|
}, 50);
|
||||||
|
|
||||||
// WebSocket management (use global scope to avoid redeclaration)
|
// WebSocket management (use global scope to avoid redeclaration)
|
||||||
if (typeof window.currentWebSocket === 'undefined') {
|
if (typeof window.currentWebSocket === 'undefined') {
|
||||||
@@ -455,13 +660,19 @@ async function controlUnit(unitId, action) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.status === 'ok') {
|
if (result.status === 'ok') {
|
||||||
// Reload the live view to update status
|
// Give device more time to change state before checking
|
||||||
|
// Start/Stop commands need time to propagate
|
||||||
|
setTimeout(() => {
|
||||||
|
checkMeasurementState();
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
// Reload the live view to update all status displays
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
||||||
target: '#live-view-panel',
|
target: '#live-view-panel',
|
||||||
swap: 'innerHTML'
|
swap: 'innerHTML'
|
||||||
});
|
});
|
||||||
}, 500);
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
alert(`Error: ${result.detail || 'Unknown error'}`);
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
@@ -472,7 +683,9 @@ async function controlUnit(unitId, action) {
|
|||||||
|
|
||||||
|
|
||||||
// Auto-refresh status every 30 seconds
|
// Auto-refresh status every 30 seconds
|
||||||
let refreshInterval;
|
if (typeof window.refreshInterval === 'undefined') {
|
||||||
|
window.refreshInterval = null;
|
||||||
|
}
|
||||||
const REFRESH_INTERVAL_MS = 30000; // 30 seconds
|
const REFRESH_INTERVAL_MS = 30000; // 30 seconds
|
||||||
const unit_id = '{{ unit.id }}';
|
const unit_id = '{{ unit.id }}';
|
||||||
|
|
||||||
@@ -556,6 +769,182 @@ function stopAutoRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start auto-refresh when page loads
|
// Start auto-refresh when page loads
|
||||||
|
|
||||||
|
|
||||||
|
// Backend-Synced Measurement Timer
|
||||||
|
// Uses measurement_start_time from backend (tracked via state transitions)
|
||||||
|
// This works even for manually-started measurements!
|
||||||
|
if (typeof window.timerInterval === 'undefined') {
|
||||||
|
window.timerInterval = null;
|
||||||
|
window.measurementStartTime = null; // ISO string from backend
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format elapsed time as HH:MM:SS
|
||||||
|
function formatElapsedTime(milliseconds) {
|
||||||
|
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
return hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update timer display based on backend measurement_start_time
|
||||||
|
function updateTimerDisplay() {
|
||||||
|
const timerElement = document.getElementById('timer-display');
|
||||||
|
if (!timerElement) {
|
||||||
|
console.error('Timer display element not found!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.measurementStartTime) {
|
||||||
|
timerElement.textContent = '00:00:00';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate elapsed time from backend-provided start time (UTC)
|
||||||
|
// Ensure the timestamp is treated as UTC by adding 'Z' if not present
|
||||||
|
let timestamp = window.measurementStartTime;
|
||||||
|
if (!timestamp.endsWith('Z') && !timestamp.includes('+')) {
|
||||||
|
timestamp = timestamp + 'Z';
|
||||||
|
}
|
||||||
|
const startTime = new Date(timestamp);
|
||||||
|
const elapsed = Date.now() - startTime.getTime();
|
||||||
|
const formattedTime = formatElapsedTime(elapsed);
|
||||||
|
timerElement.textContent = formattedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync timer with backend data (with FTP fallback)
|
||||||
|
async function syncTimerWithBackend(measurementState, measurementStartTime) {
|
||||||
|
// Device returns "Start" when measuring, "Stop" when stopped
|
||||||
|
const isMeasuring = measurementState === 'Start';
|
||||||
|
|
||||||
|
if (isMeasuring && measurementStartTime) {
|
||||||
|
// Measurement is running - check both backend and FTP timestamps
|
||||||
|
// Use whichever is earlier (older = actual measurement start)
|
||||||
|
|
||||||
|
// First check FTP for potentially older timestamp
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'ok' && result.latest_timestamp) {
|
||||||
|
const backendTime = new Date(measurementStartTime + 'Z');
|
||||||
|
const ftpTime = new Date(result.latest_timestamp + 'Z');
|
||||||
|
|
||||||
|
// Use the earlier timestamp (represents actual measurement start)
|
||||||
|
if (ftpTime < backendTime) {
|
||||||
|
window.measurementStartTime = result.latest_timestamp;
|
||||||
|
window.timerSource = 'ftp';
|
||||||
|
console.log('Timer synced with FTP folder (earlier):', result.latest_folder, '@', result.latest_timestamp);
|
||||||
|
} else {
|
||||||
|
window.measurementStartTime = measurementStartTime;
|
||||||
|
window.timerSource = 'backend';
|
||||||
|
console.log('Timer synced with backend state (earlier):', measurementStartTime);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No FTP timestamp, use backend
|
||||||
|
window.measurementStartTime = measurementStartTime;
|
||||||
|
window.timerSource = 'backend';
|
||||||
|
console.log('Timer synced with backend state:', measurementStartTime);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check FTP timestamp:', error);
|
||||||
|
// Fallback to backend on error
|
||||||
|
window.measurementStartTime = measurementStartTime;
|
||||||
|
window.timerSource = 'backend';
|
||||||
|
console.log('Timer synced with backend state (FTP check failed):', measurementStartTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start interval if not already running
|
||||||
|
if (!window.timerInterval) {
|
||||||
|
window.timerInterval = setInterval(updateTimerDisplay, 1000);
|
||||||
|
console.log('Timer display started');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update display immediately
|
||||||
|
updateTimerDisplay();
|
||||||
|
} else if (isMeasuring && !measurementStartTime) {
|
||||||
|
// Device is measuring but backend doesn't have start time yet
|
||||||
|
// Try FTP fallback to get measurement start from latest folder timestamp
|
||||||
|
if (!window.measurementStartTime || window.timerSource !== 'ftp') {
|
||||||
|
console.log('Device measuring but no backend start time - checking FTP fallback...');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'ok' && result.latest_timestamp) {
|
||||||
|
window.measurementStartTime = result.latest_timestamp;
|
||||||
|
window.timerSource = 'ftp';
|
||||||
|
console.log('Timer synced with FTP folder:', result.latest_folder, '@', result.latest_timestamp);
|
||||||
|
|
||||||
|
// Start timer interval if not already running
|
||||||
|
if (!window.timerInterval) {
|
||||||
|
window.timerInterval = setInterval(updateTimerDisplay, 1000);
|
||||||
|
console.log('Timer display started (FTP source)');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTimerDisplay();
|
||||||
|
} else {
|
||||||
|
console.log('No FTP timestamp available');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get FTP timestamp:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not measuring - stop timer
|
||||||
|
if (window.timerInterval) {
|
||||||
|
clearInterval(window.timerInterval);
|
||||||
|
window.timerInterval = null;
|
||||||
|
console.log('Timer display stopped');
|
||||||
|
}
|
||||||
|
window.measurementStartTime = null;
|
||||||
|
window.timerSource = null;
|
||||||
|
document.getElementById('timer-display').textContent = '00:00:00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check measurement state and sync timer
|
||||||
|
async function checkMeasurementState() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unit_id}/live`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'ok' && result.data) {
|
||||||
|
await syncTimerWithBackend(
|
||||||
|
result.data.measurement_state,
|
||||||
|
result.data.measurement_start_time
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check measurement state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize timer immediately (this partial is loaded via HTMX, so DOMContentLoaded may have already fired)
|
||||||
|
if (typeof window.timerInitialized === 'undefined') {
|
||||||
|
window.timerInitialized = true;
|
||||||
|
|
||||||
|
// Execute initialization immediately
|
||||||
|
(async function() {
|
||||||
|
console.log('Timer initialization: Starting immediately');
|
||||||
|
|
||||||
|
// Check measurement state immediately
|
||||||
|
await checkMeasurementState();
|
||||||
|
|
||||||
|
// Load store name (index number)
|
||||||
|
await loadStoreName();
|
||||||
|
|
||||||
|
// Check FTP status and update buttons
|
||||||
|
await checkFTPStatus();
|
||||||
|
|
||||||
|
// Check measurement state periodically (every 5 seconds)
|
||||||
|
setInterval(checkMeasurementState, 5000);
|
||||||
|
|
||||||
|
console.log('Timer initialization: Complete, polling every 5 seconds');
|
||||||
|
})();
|
||||||
|
}
|
||||||
document.addEventListener('DOMContentLoaded', startAutoRefresh);
|
document.addEventListener('DOMContentLoaded', startAutoRefresh);
|
||||||
|
|
||||||
// Cleanup on page unload
|
// Cleanup on page unload
|
||||||
@@ -564,4 +953,510 @@ window.addEventListener('beforeunload', function() {
|
|||||||
window.currentWebSocket.close();
|
window.currentWebSocket.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// File Browser Functions
|
||||||
|
if (typeof window.currentPath === 'undefined') {
|
||||||
|
window.currentPath = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format file size
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString();
|
||||||
|
} catch (e) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable FTP on device
|
||||||
|
async function enableFTP() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unit_id}/ftp/enable`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
document.getElementById('ftp-status').classList.add('hidden');
|
||||||
|
updateFTPButtons(true);
|
||||||
|
refreshFileList();
|
||||||
|
} else {
|
||||||
|
alert('Failed to enable FTP: ' + (result.detail || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to enable FTP: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableFTP() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unit_id}/ftp/disable`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
updateFTPButtons(false);
|
||||||
|
alert('FTP disabled. TCP commands are now available.');
|
||||||
|
} else {
|
||||||
|
alert('Failed to disable FTP: ' + (result.detail || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to disable FTP: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFTPButtons(ftpEnabled) {
|
||||||
|
const enableBtn = document.getElementById('enable-ftp-btn');
|
||||||
|
const disableBtn = document.getElementById('disable-ftp-btn');
|
||||||
|
|
||||||
|
console.log('updateFTPButtons called with ftpEnabled:', ftpEnabled);
|
||||||
|
console.log('Enable button element:', enableBtn);
|
||||||
|
console.log('Disable button element:', disableBtn);
|
||||||
|
|
||||||
|
if (enableBtn && disableBtn) {
|
||||||
|
if (ftpEnabled) {
|
||||||
|
enableBtn.classList.add('hidden');
|
||||||
|
disableBtn.classList.remove('hidden');
|
||||||
|
console.log('FTP is enabled - showing Disable FTP button');
|
||||||
|
} else {
|
||||||
|
enableBtn.classList.remove('hidden');
|
||||||
|
disableBtn.classList.add('hidden');
|
||||||
|
console.log('FTP is disabled - showing Enable FTP button');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('FTP button elements not found in DOM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkFTPStatus() {
|
||||||
|
console.log('checkFTPStatus: Checking FTP status for unit_id:', unit_id);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unit_id}/ftp/status`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
console.log('checkFTPStatus: Response:', result);
|
||||||
|
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
console.log('checkFTPStatus: FTP enabled status:', result.ftp_enabled);
|
||||||
|
updateFTPButtons(result.ftp_enabled);
|
||||||
|
} else {
|
||||||
|
console.error('checkFTPStatus: Unexpected response status:', result.status);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('checkFTPStatus: Failed to check FTP status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock Management Functions
|
||||||
|
async function syncClockToNow() {
|
||||||
|
const now = new Date();
|
||||||
|
// Format as YYYY-MM-DDTHH:mm for datetime-local input
|
||||||
|
const localDateTime = new Date(now.getTime() - (now.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
|
||||||
|
|
||||||
|
// Update the input field
|
||||||
|
document.getElementById('clock-input').value = localDateTime;
|
||||||
|
|
||||||
|
// Auto-submit
|
||||||
|
await setDeviceClock();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDeviceClock() {
|
||||||
|
const clockInput = document.getElementById('clock-input');
|
||||||
|
const statusDiv = document.getElementById('clock-status');
|
||||||
|
|
||||||
|
if (!clockInput.value) {
|
||||||
|
showClockStatus('Please select a date and time', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert datetime-local to NL43 format: YYYY/MM/DD,HH:MM:SS
|
||||||
|
const dateTime = new Date(clockInput.value);
|
||||||
|
const nl43Format = dateTime.getFullYear() + '/' +
|
||||||
|
String(dateTime.getMonth() + 1).padStart(2, '0') + '/' +
|
||||||
|
String(dateTime.getDate()).padStart(2, '0') + ',' +
|
||||||
|
String(dateTime.getHours()).padStart(2, '0') + ':' +
|
||||||
|
String(dateTime.getMinutes()).padStart(2, '0') + ':' +
|
||||||
|
String(dateTime.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
showClockStatus('Setting device clock...', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unit_id}/clock`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ datetime: nl43Format })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.status === 'ok') {
|
||||||
|
showClockStatus('✓ Clock set successfully to ' + dateTime.toLocaleString(), 'success');
|
||||||
|
} else {
|
||||||
|
showClockStatus('✗ Failed: ' + (result.detail || 'Unknown error'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showClockStatus('✗ Failed: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showClockStatus(message, type) {
|
||||||
|
const statusDiv = document.getElementById('clock-status');
|
||||||
|
statusDiv.textContent = message;
|
||||||
|
statusDiv.classList.remove('hidden', 'text-green-600', 'text-red-600', 'text-blue-600');
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
statusDiv.classList.add('text-green-600', 'dark:text-green-400');
|
||||||
|
} else if (type === 'error') {
|
||||||
|
statusDiv.classList.add('text-red-600', 'dark:text-red-400');
|
||||||
|
} else {
|
||||||
|
statusDiv.classList.add('text-blue-600', 'dark:text-blue-400');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store Name (Index) Management Functions
|
||||||
|
async function loadStoreName() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unit_id}/index-number`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.status === 'ok') {
|
||||||
|
const indexNum = result.index_number;
|
||||||
|
document.getElementById('store-name-display').textContent = indexNum;
|
||||||
|
document.getElementById('store-name-input').value = parseInt(indexNum);
|
||||||
|
|
||||||
|
// Check overwrite status after loading
|
||||||
|
await checkOverwriteStatus();
|
||||||
|
} else {
|
||||||
|
console.error('Failed to load store name:', result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading store name:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStoreName() {
|
||||||
|
const input = document.getElementById('store-name-input');
|
||||||
|
const statusDiv = document.getElementById('store-name-status');
|
||||||
|
|
||||||
|
const index = parseInt(input.value);
|
||||||
|
|
||||||
|
if (isNaN(index) || index < 0 || index > 9999) {
|
||||||
|
showStoreNameStatus('Please enter a number between 0 and 9999', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showStoreNameStatus('Setting store name...', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unit_id}/index-number`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ index: index })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.status === 'ok') {
|
||||||
|
showStoreNameStatus('✓ Store name set successfully to ' + index.toString().padStart(4, '0'), 'success');
|
||||||
|
// Update display
|
||||||
|
document.getElementById('store-name-display').textContent = index.toString().padStart(4, '0');
|
||||||
|
// Check overwrite status for new index
|
||||||
|
await checkOverwriteStatus();
|
||||||
|
} else {
|
||||||
|
showStoreNameStatus('✗ Failed: ' + (result.detail || 'Unknown error'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showStoreNameStatus('✗ Failed: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStoreNameStatus(message, type) {
|
||||||
|
const statusDiv = document.getElementById('store-name-status');
|
||||||
|
statusDiv.textContent = message;
|
||||||
|
statusDiv.classList.remove('hidden', 'text-green-600', 'text-red-600', 'text-blue-600');
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
statusDiv.classList.add('text-green-600', 'dark:text-green-400');
|
||||||
|
} else if (type === 'error') {
|
||||||
|
statusDiv.classList.add('text-red-600', 'dark:text-red-400');
|
||||||
|
} else {
|
||||||
|
statusDiv.classList.add('text-blue-600', 'dark:text-blue-400');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Overwrite Status
|
||||||
|
async function checkOverwriteStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unit_id}/overwrite-check`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
const warningDiv = document.getElementById('overwrite-warning');
|
||||||
|
const indicator = document.getElementById('overwrite-indicator');
|
||||||
|
|
||||||
|
if (response.ok && result.status === 'ok') {
|
||||||
|
if (result.will_overwrite) {
|
||||||
|
// Show warning
|
||||||
|
warningDiv.classList.remove('hidden');
|
||||||
|
indicator.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'dark:bg-green-900', 'dark:text-green-200');
|
||||||
|
indicator.classList.add('bg-yellow-100', 'text-yellow-800', 'dark:bg-yellow-900', 'dark:text-yellow-200');
|
||||||
|
indicator.textContent = 'Data Exists';
|
||||||
|
} else {
|
||||||
|
// Hide warning
|
||||||
|
warningDiv.classList.add('hidden');
|
||||||
|
indicator.classList.remove('hidden', 'bg-yellow-100', 'text-yellow-800', 'dark:bg-yellow-900', 'dark:text-yellow-200');
|
||||||
|
indicator.classList.add('bg-green-100', 'text-green-800', 'dark:bg-green-900', 'dark:text-green-200');
|
||||||
|
indicator.textContent = 'Safe';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Hide on error
|
||||||
|
warningDiv.classList.add('hidden');
|
||||||
|
indicator.classList.add('hidden');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking overwrite status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-increment to find next available index
|
||||||
|
async function autoIncrementStoreName() {
|
||||||
|
showStoreNameStatus('Finding next available index...', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentIndexResponse = await fetch(`/api/slmm/${unit_id}/index-number`);
|
||||||
|
const currentIndexResult = await currentIndexResponse.json();
|
||||||
|
|
||||||
|
if (!currentIndexResponse.ok || currentIndexResult.status !== 'ok') {
|
||||||
|
showStoreNameStatus('✗ Failed to get current index', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentIndex = parseInt(currentIndexResult.index_number);
|
||||||
|
let foundEmpty = false;
|
||||||
|
let checkedCount = 0;
|
||||||
|
const maxChecks = 100; // Safety limit
|
||||||
|
|
||||||
|
// Start from next index
|
||||||
|
currentIndex++;
|
||||||
|
|
||||||
|
while (!foundEmpty && checkedCount < maxChecks && currentIndex <= 9999) {
|
||||||
|
// Set the index
|
||||||
|
const setResponse = await fetch(`/api/slmm/${unit_id}/index-number`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ index: currentIndex })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!setResponse.ok) {
|
||||||
|
currentIndex++;
|
||||||
|
checkedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if data exists at this index
|
||||||
|
const checkResponse = await fetch(`/api/slmm/${unit_id}/overwrite-check`);
|
||||||
|
const checkResult = await checkResponse.json();
|
||||||
|
|
||||||
|
if (checkResponse.ok && checkResult.status === 'ok') {
|
||||||
|
if (!checkResult.will_overwrite) {
|
||||||
|
// Found empty slot!
|
||||||
|
foundEmpty = true;
|
||||||
|
document.getElementById('store-name-display').textContent = currentIndex.toString().padStart(4, '0');
|
||||||
|
document.getElementById('store-name-input').value = currentIndex;
|
||||||
|
showStoreNameStatus(`✓ Found empty slot at index ${currentIndex.toString().padStart(4, '0')}`, 'success');
|
||||||
|
await checkOverwriteStatus();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIndex++;
|
||||||
|
checkedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundEmpty) {
|
||||||
|
if (currentIndex > 9999) {
|
||||||
|
showStoreNameStatus('✗ No empty slots found (reached max 9999)', 'error');
|
||||||
|
} else {
|
||||||
|
showStoreNameStatus(`✗ No empty slot found in next ${maxChecks} indices`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showStoreNameStatus('✗ Error: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to directory
|
||||||
|
function navigateToDir(path) {
|
||||||
|
window.currentPath = path;
|
||||||
|
document.getElementById('current-path').textContent = path;
|
||||||
|
loadFiles(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go up one directory
|
||||||
|
function navigateUp() {
|
||||||
|
const parts = window.currentPath.split('/').filter(p => p);
|
||||||
|
parts.pop();
|
||||||
|
const newPath = '/' + parts.join('/');
|
||||||
|
navigateToDir(newPath || '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download file
|
||||||
|
async function downloadFile(path, filename) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unit_id}/ftp/download`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ remote_path: path })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Download failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create blob and download
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to download file: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load files from path
|
||||||
|
async function loadFiles(path) {
|
||||||
|
const fileList = document.getElementById('file-list');
|
||||||
|
const loading = document.getElementById('file-list-loading');
|
||||||
|
const error = document.getElementById('file-list-error');
|
||||||
|
const ftpStatus = document.getElementById('ftp-status');
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
fileList.classList.add('hidden');
|
||||||
|
loading.classList.remove('hidden');
|
||||||
|
error.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unit_id}/ftp/files?path=${encodeURIComponent(path)}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
ftpStatus.classList.add('hidden');
|
||||||
|
displayFiles(result.files || []);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.detail || 'Failed to load files');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
|
||||||
|
// Check if it's an FTP disabled error
|
||||||
|
if (err.message.includes('FTP') || err.message.includes('502')) {
|
||||||
|
ftpStatus.classList.remove('hidden');
|
||||||
|
fileList.classList.remove('hidden');
|
||||||
|
fileList.innerHTML = '<div class="p-8 text-center text-gray-500 dark:text-gray-400">FTP is disabled on the device</div>';
|
||||||
|
} else {
|
||||||
|
error.classList.remove('hidden');
|
||||||
|
document.getElementById('file-list-error-text').textContent = err.message;
|
||||||
|
fileList.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display files in the list
|
||||||
|
function displayFiles(files) {
|
||||||
|
const fileList = document.getElementById('file-list');
|
||||||
|
const loading = document.getElementById('file-list-loading');
|
||||||
|
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
fileList.classList.remove('hidden');
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
fileList.innerHTML = '<div class="p-8 text-center text-gray-500 dark:text-gray-400">No files found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: directories first, then files
|
||||||
|
files.sort((a, b) => {
|
||||||
|
if (a.is_dir && !b.is_dir) return -1;
|
||||||
|
if (!a.is_dir && b.is_dir) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Add "up" button if not at root
|
||||||
|
if (window.currentPath !== '/') {
|
||||||
|
html += `
|
||||||
|
<div class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer flex items-center"
|
||||||
|
onclick="navigateUp()">
|
||||||
|
<svg class="w-5 h-5 text-gray-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300 font-medium">..</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const icon = file.is_dir ?
|
||||||
|
'<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>' :
|
||||||
|
'<svg class="w-5 h-5 text-gray-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>';
|
||||||
|
|
||||||
|
const clickHandler = file.is_dir ?
|
||||||
|
`navigateToDir('${file.path}')` :
|
||||||
|
`downloadFile('${file.path}', '${file.name}')`;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer flex items-center justify-between"
|
||||||
|
onclick="${clickHandler}">
|
||||||
|
<div class="flex items-center flex-1 min-w-0">
|
||||||
|
${icon}
|
||||||
|
<div class="ml-3 flex-1 min-w-0">
|
||||||
|
<p class="text-sm text-gray-900 dark:text-white truncate">${file.name}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
${file.is_dir ? 'Folder' : formatFileSize(file.size)}
|
||||||
|
${file.modified_timestamp ? ' • ' + formatDate(file.modified_timestamp) : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${!file.is_dir ? `
|
||||||
|
<button onclick="event.stopPropagation(); downloadFile('${file.path}', '${file.name}')"
|
||||||
|
class="ml-2 p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
fileList.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh file list
|
||||||
|
function refreshFileList() {
|
||||||
|
loadFiles(window.currentPath);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||