chore: modular monolith folder split (no behavior change)

This commit is contained in:
serversdwn
2026-01-08 20:54:30 +00:00
parent 893cb96e8d
commit 991aaca34b
90 changed files with 16129 additions and 28 deletions

0
app/__init__.py Normal file
View File

0
app/api/__init__.py Normal file
View File

13
app/api/dashboard.py Normal file
View 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
View 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
View File

20
app/core/config.py Normal file
View 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
View 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
View 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
View File

0
app/seismo/__init__.py Normal file
View File

36
app/seismo/database.py Normal file
View 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
View 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)

View File

View 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()
}

View 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"]}
)

View 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}

View 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)

View 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}

View 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}

View 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 ""
}
)

View 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)}")

View 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
View 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
}

View File

View 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

View 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

View 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
View File

36
app/slm/database.py Normal file
View 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
View 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

View File

View 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)
}

View 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
View 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"
})

View File

0
app/ui/__init__.py Normal file
View File

92
app/ui/routes.py Normal file
View 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"}
)

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 %}

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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 %}

View 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

File diff suppressed because it is too large Load Diff

View 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 %}

File diff suppressed because it is too large Load Diff

View 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 %}

View 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 %}

File diff suppressed because it is too large Load Diff

View File

@@ -524,4 +524,6 @@ def health_check():
if __name__ == "__main__":
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)

View File

@@ -4,14 +4,14 @@ services:
terra-view-prod:
build: .
container_name: terra-view
ports:
- "8001:8001"
network_mode: host
volumes:
- ./data:/app/data
environment:
- PYTHONUNBUFFERED=1
- ENVIRONMENT=production
- SLMM_BASE_URL=http://slmm:8100
- PORT=8001
- SLMM_BASE_URL=http://localhost:8100
restart: unless-stopped
depends_on:
- slmm
@@ -26,19 +26,21 @@ services:
terra-view-dev:
build: .
container_name: terra-view-dev
ports:
- "1001:8001"
network_mode: host
volumes:
- ./data-dev:/app/data
environment:
- PYTHONUNBUFFERED=1
- ENVIRONMENT=development
- SLMM_BASE_URL=http://slmm:8100
- PORT=1001
- SLMM_BASE_URL=http://localhost:8100
restart: unless-stopped
depends_on:
- slmm
profiles:
- dev
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
test: ["CMD", "curl", "-f", "http://localhost:1001/health"]
interval: 30s
timeout: 10s
retries: 3
@@ -50,8 +52,7 @@ services:
context: ../../slmm
dockerfile: Dockerfile
container_name: slmm
ports:
- "8100:8100"
network_mode: host
volumes:
- ../../slmm/data:/app/data
environment:

View File

@@ -23,18 +23,31 @@
{% 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 %}
<!-- Measurement Status Badge & Timer -->
<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 %}
<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>
</div>
@@ -209,6 +222,171 @@
</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>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@@ -236,9 +414,14 @@ function initializeChart() {
if (window.liveChart && typeof window.liveChart.destroy === 'function') {
console.log('Destroying existing chart');
window.liveChart.destroy();
window.liveChart = null;
}
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Failed to get 2D context from canvas');
return;
}
console.log('Creating new chart...');
// Dark mode detection
@@ -246,7 +429,8 @@ function initializeChart() {
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, {
try {
window.liveChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
@@ -317,12 +501,33 @@ 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
console.log('Executing initializeChart...');
initializeChart();
// Use a small delay to ensure DOM is fully ready
setTimeout(() => {
initializeChart();
}, 50);
// WebSocket management (use global scope to avoid redeclaration)
if (typeof window.currentWebSocket === 'undefined') {
@@ -455,13 +660,19 @@ async function controlUnit(unitId, action) {
const result = await response.json();
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(() => {
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
target: '#live-view-panel',
swap: 'innerHTML'
});
}, 500);
}, 2000);
} else {
alert(`Error: ${result.detail || 'Unknown error'}`);
}
@@ -472,7 +683,9 @@ async function controlUnit(unitId, action) {
// Auto-refresh status every 30 seconds
let refreshInterval;
if (typeof window.refreshInterval === 'undefined') {
window.refreshInterval = null;
}
const REFRESH_INTERVAL_MS = 30000; // 30 seconds
const unit_id = '{{ unit.id }}';
@@ -556,6 +769,182 @@ function stopAutoRefresh() {
}
// 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);
// Cleanup on page unload
@@ -564,4 +953,510 @@ window.addEventListener('beforeunload', function() {
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>