chore: modular monolith folder split (no behavior change)
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
13
app/api/dashboard.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
API Aggregation Layer - Dashboard endpoints
|
||||
Composes data from multiple feature modules
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard-aggregation"])
|
||||
|
||||
# TODO: Implement aggregation endpoints that combine data from
|
||||
# app.seismo and app.slm modules
|
||||
|
||||
# For now, individual feature modules expose their own APIs directly
|
||||
# Future: Add cross-feature aggregation here
|
||||
13
app/api/roster.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
API Aggregation Layer - Roster endpoints
|
||||
Aggregates roster data from all feature modules
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/api/roster-aggregation", tags=["roster-aggregation"])
|
||||
|
||||
# TODO: Implement unified roster endpoints that combine data from
|
||||
# app.seismo and app.slm modules
|
||||
|
||||
# For now, individual feature modules expose their own roster APIs
|
||||
# Future: Add cross-feature roster aggregation here
|
||||
0
app/core/__init__.py
Normal file
20
app/core/config.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Core configuration for Terra-View application
|
||||
"""
|
||||
import os
|
||||
|
||||
# Application
|
||||
APP_NAME = "Terra-View"
|
||||
VERSION = "1.0.0"
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
|
||||
# Ports
|
||||
PORT = int(os.getenv("PORT", 8001))
|
||||
|
||||
# External Services
|
||||
SLMM_API_URL = os.getenv("SLMM_API_URL", "http://localhost:8100")
|
||||
|
||||
# Database URLs (feature-specific)
|
||||
SEISMO_DATABASE_URL = "sqlite:///./data/seismo.db"
|
||||
SLM_DATABASE_URL = "sqlite:///./data/slm.db"
|
||||
MODEM_DATABASE_URL = "sqlite:///./data/modem.db"
|
||||
31
app/core/database.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
|
||||
# Ensure data directory exists
|
||||
os.makedirs("data", exist_ok=True)
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency for database sessions"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""Get a database session directly (not as a dependency)"""
|
||||
return SessionLocal()
|
||||
211
app/main.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Terra-View - Unified monitoring platform for device fleets
|
||||
Modular monolith architecture with strict feature boundaries
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import configuration
|
||||
from app.core.config import APP_NAME, VERSION, ENVIRONMENT
|
||||
|
||||
# Import UI routes
|
||||
from app.ui import routes as ui_routes
|
||||
|
||||
# Import feature module routers (seismo)
|
||||
from app.seismo.routers import (
|
||||
roster as seismo_roster,
|
||||
units as seismo_units,
|
||||
photos as seismo_photos,
|
||||
roster_edit as seismo_roster_edit,
|
||||
dashboard as seismo_dashboard,
|
||||
dashboard_tabs as seismo_dashboard_tabs,
|
||||
activity as seismo_activity,
|
||||
seismo_dashboard as seismo_seismo_dashboard,
|
||||
settings as seismo_settings,
|
||||
)
|
||||
from app.seismo import routes as seismo_legacy_routes
|
||||
|
||||
# Import feature module routers (SLM)
|
||||
from app.slm.routers import (
|
||||
nl43_proxy as slm_nl43_proxy,
|
||||
dashboard as slm_dashboard,
|
||||
ui as slm_ui,
|
||||
)
|
||||
|
||||
# Import API aggregation layer (placeholder for now)
|
||||
from app.api import dashboard as api_dashboard
|
||||
from app.api import roster as api_roster
|
||||
|
||||
# Initialize database tables
|
||||
from app.seismo.database import engine as seismo_engine, Base as SeismoBase
|
||||
SeismoBase.metadata.create_all(bind=seismo_engine)
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title=APP_NAME,
|
||||
description="Unified monitoring platform for seismograph, modem, and sound level meter fleets",
|
||||
version=VERSION
|
||||
)
|
||||
|
||||
# Add validation error handler to log details
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
logger.error(f"Validation error on {request.url}: {exc.errors()}")
|
||||
logger.error(f"Body: {await request.body()}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": exc.errors()}
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory="app/ui/static"), name="static")
|
||||
|
||||
# Middleware to add environment to request state
|
||||
@app.middleware("http")
|
||||
async def add_environment_to_context(request: Request, call_next):
|
||||
"""Middleware to add environment variable to request state"""
|
||||
request.state.environment = ENVIRONMENT
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# ===== INCLUDE ROUTERS =====
|
||||
|
||||
# UI Layer (HTML pages)
|
||||
app.include_router(ui_routes.router)
|
||||
|
||||
# Seismograph Feature Module APIs
|
||||
app.include_router(seismo_roster.router)
|
||||
app.include_router(seismo_units.router)
|
||||
app.include_router(seismo_photos.router)
|
||||
app.include_router(seismo_roster_edit.router)
|
||||
app.include_router(seismo_dashboard.router)
|
||||
app.include_router(seismo_dashboard_tabs.router)
|
||||
app.include_router(seismo_activity.router)
|
||||
app.include_router(seismo_seismo_dashboard.router)
|
||||
app.include_router(seismo_settings.router)
|
||||
app.include_router(seismo_legacy_routes.router)
|
||||
|
||||
# SLM Feature Module APIs
|
||||
app.include_router(slm_nl43_proxy.router)
|
||||
app.include_router(slm_dashboard.router)
|
||||
app.include_router(slm_ui.router)
|
||||
|
||||
# API Aggregation Layer (future cross-feature endpoints)
|
||||
# app.include_router(api_dashboard.router) # TODO: Implement aggregation
|
||||
# app.include_router(api_roster.router) # TODO: Implement aggregation
|
||||
|
||||
# ===== ADDITIONAL ROUTES FROM OLD MAIN.PY =====
|
||||
# These will need to be migrated to appropriate modules
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from typing import List, Dict
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import Depends
|
||||
|
||||
from app.seismo.database import get_db
|
||||
from app.seismo.services.snapshot import emit_status_snapshot
|
||||
from app.seismo.models import IgnoredUnit
|
||||
|
||||
# TODO: Move these to appropriate feature modules or UI layer
|
||||
|
||||
@app.post("/api/sync-edits")
|
||||
async def sync_edits(request: dict, db: Session = Depends(get_db)):
|
||||
"""Process offline edit queue and sync to database"""
|
||||
# TODO: Move to seismo module
|
||||
from app.seismo.models import RosterUnit
|
||||
|
||||
class EditItem(BaseModel):
|
||||
id: int
|
||||
unitId: str
|
||||
changes: Dict
|
||||
timestamp: int
|
||||
|
||||
class SyncEditsRequest(BaseModel):
|
||||
edits: List[EditItem]
|
||||
|
||||
sync_request = SyncEditsRequest(**request)
|
||||
results = []
|
||||
synced_ids = []
|
||||
|
||||
for edit in sync_request.edits:
|
||||
try:
|
||||
unit = db.query(RosterUnit).filter_by(id=edit.unitId).first()
|
||||
|
||||
if not unit:
|
||||
results.append({
|
||||
"id": edit.id,
|
||||
"status": "error",
|
||||
"reason": f"Unit {edit.unitId} not found"
|
||||
})
|
||||
continue
|
||||
|
||||
for key, value in edit.changes.items():
|
||||
if hasattr(unit, key):
|
||||
if key in ['deployed', 'retired']:
|
||||
setattr(unit, key, value in ['true', True, 'True', '1', 1])
|
||||
else:
|
||||
setattr(unit, key, value if value != '' else None)
|
||||
|
||||
db.commit()
|
||||
|
||||
results.append({
|
||||
"id": edit.id,
|
||||
"status": "success"
|
||||
})
|
||||
synced_ids.append(edit.id)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
results.append({
|
||||
"id": edit.id,
|
||||
"status": "error",
|
||||
"reason": str(e)
|
||||
})
|
||||
|
||||
synced_count = len(synced_ids)
|
||||
|
||||
return JSONResponse({
|
||||
"synced": synced_count,
|
||||
"total": len(sync_request.edits),
|
||||
"synced_ids": synced_ids,
|
||||
"results": results
|
||||
})
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"message": f"{APP_NAME} v{VERSION}",
|
||||
"status": "running",
|
||||
"version": VERSION,
|
||||
"modules": ["seismo", "slm"]
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
from app.core.config import PORT
|
||||
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||
0
app/modem/__init__.py
Normal file
0
app/seismo/__init__.py
Normal file
36
app/seismo/database.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Seismograph feature module database connection
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
|
||||
# Ensure data directory exists
|
||||
os.makedirs("data", exist_ok=True)
|
||||
|
||||
# For now, we'll use the old database (seismo_fleet.db) until we migrate
|
||||
# TODO: Migrate to seismo.db
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency for database sessions"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""Get a database session directly (not as a dependency)"""
|
||||
return SessionLocal()
|
||||
110
app/seismo/models.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
|
||||
from datetime import datetime
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
class Emitter(Base):
|
||||
__tablename__ = "emitters"
|
||||
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
unit_type = Column(String, nullable=False)
|
||||
last_seen = Column(DateTime, default=datetime.utcnow)
|
||||
last_file = Column(String, nullable=False)
|
||||
status = Column(String, nullable=False)
|
||||
notes = Column(String, nullable=True)
|
||||
|
||||
|
||||
class RosterUnit(Base):
|
||||
"""
|
||||
Roster table: represents our *intended assignment* of a unit.
|
||||
This is editable from the GUI.
|
||||
|
||||
Supports multiple device types (seismograph, modem, sound_level_meter) with type-specific fields.
|
||||
"""
|
||||
__tablename__ = "roster"
|
||||
|
||||
# Core fields (all device types)
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
unit_type = Column(String, default="series3") # Backward compatibility
|
||||
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "sound_level_meter"
|
||||
deployed = Column(Boolean, default=True)
|
||||
retired = Column(Boolean, default=False)
|
||||
note = Column(String, nullable=True)
|
||||
project_id = Column(String, nullable=True)
|
||||
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
||||
address = Column(String, nullable=True) # Human-readable address
|
||||
coordinates = Column(String, nullable=True) # Lat,Lon format: "34.0522,-118.2437"
|
||||
last_updated = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Seismograph-specific fields (nullable for modems and SLMs)
|
||||
last_calibrated = Column(Date, nullable=True)
|
||||
next_calibration_due = Column(Date, nullable=True)
|
||||
|
||||
# Modem assignment (shared by seismographs and SLMs)
|
||||
deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit (device_type=modem)
|
||||
|
||||
# Modem-specific fields (nullable for seismographs and SLMs)
|
||||
ip_address = Column(String, nullable=True)
|
||||
phone_number = Column(String, nullable=True)
|
||||
hardware_model = Column(String, nullable=True)
|
||||
|
||||
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
||||
slm_host = Column(String, nullable=True) # Device IP or hostname
|
||||
slm_tcp_port = Column(Integer, nullable=True) # TCP control port (default 2255)
|
||||
slm_ftp_port = Column(Integer, nullable=True) # FTP data retrieval port (default 21)
|
||||
slm_model = Column(String, nullable=True) # NL-43, NL-53, etc.
|
||||
slm_serial_number = Column(String, nullable=True) # Device serial number
|
||||
slm_frequency_weighting = Column(String, nullable=True) # A, C, Z
|
||||
slm_time_weighting = Column(String, nullable=True) # F (Fast), S (Slow), I (Impulse)
|
||||
slm_measurement_range = Column(String, nullable=True) # e.g., "30-130 dB"
|
||||
slm_last_check = Column(DateTime, nullable=True) # Last communication check
|
||||
|
||||
|
||||
class IgnoredUnit(Base):
|
||||
"""
|
||||
Ignored units: units that report but should be filtered out from unknown emitters.
|
||||
Used to suppress noise from old projects.
|
||||
"""
|
||||
__tablename__ = "ignored_units"
|
||||
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
reason = Column(String, nullable=True)
|
||||
ignored_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class UnitHistory(Base):
|
||||
"""
|
||||
Unit history: complete timeline of changes to each unit.
|
||||
Tracks note changes, status changes, deployment/benched events, and more.
|
||||
"""
|
||||
__tablename__ = "unit_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
||||
change_type = Column(String, nullable=False) # note_change, deployed_change, retired_change, etc.
|
||||
field_name = Column(String, nullable=True) # Which field changed
|
||||
old_value = Column(Text, nullable=True) # Previous value
|
||||
new_value = Column(Text, nullable=True) # New value
|
||||
changed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
source = Column(String, default="manual") # manual, csv_import, telemetry, offline_sync
|
||||
notes = Column(Text, nullable=True) # Optional reason/context for the change
|
||||
|
||||
|
||||
class UserPreferences(Base):
|
||||
"""
|
||||
User preferences: persistent storage for application settings.
|
||||
Single-row table (id=1) to store global user preferences.
|
||||
"""
|
||||
__tablename__ = "user_preferences"
|
||||
|
||||
id = Column(Integer, primary_key=True, default=1)
|
||||
timezone = Column(String, default="America/New_York")
|
||||
theme = Column(String, default="auto") # auto, light, dark
|
||||
auto_refresh_interval = Column(Integer, default=10) # seconds
|
||||
date_format = Column(String, default="MM/DD/YYYY")
|
||||
table_rows_per_page = Column(Integer, default=25)
|
||||
calibration_interval_days = Column(Integer, default=365)
|
||||
calibration_warning_days = Column(Integer, default=30)
|
||||
status_ok_threshold_hours = Column(Integer, default=12)
|
||||
status_pending_threshold_hours = Column(Integer, default=24)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
0
app/seismo/routers/__init__.py
Normal file
146
app/seismo/routers/activity.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Dict, Any
|
||||
from backend.database import get_db
|
||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["activity"])
|
||||
|
||||
PHOTOS_BASE_DIR = Path("data/photos")
|
||||
|
||||
|
||||
@router.get("/recent-activity")
|
||||
def get_recent_activity(limit: int = 20, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get recent activity feed combining unit history changes and photo uploads.
|
||||
Returns a unified timeline of events sorted by timestamp (newest first).
|
||||
"""
|
||||
activities = []
|
||||
|
||||
# Get recent history entries
|
||||
history_entries = db.query(UnitHistory)\
|
||||
.order_by(desc(UnitHistory.changed_at))\
|
||||
.limit(limit * 2)\
|
||||
.all() # Get more than needed to mix with photos
|
||||
|
||||
for entry in history_entries:
|
||||
activity = {
|
||||
"type": "history",
|
||||
"timestamp": entry.changed_at.isoformat(),
|
||||
"timestamp_unix": entry.changed_at.timestamp(),
|
||||
"unit_id": entry.unit_id,
|
||||
"change_type": entry.change_type,
|
||||
"field_name": entry.field_name,
|
||||
"old_value": entry.old_value,
|
||||
"new_value": entry.new_value,
|
||||
"source": entry.source,
|
||||
"notes": entry.notes
|
||||
}
|
||||
activities.append(activity)
|
||||
|
||||
# Get recent photos
|
||||
if PHOTOS_BASE_DIR.exists():
|
||||
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
photo_activities = []
|
||||
|
||||
for unit_dir in PHOTOS_BASE_DIR.iterdir():
|
||||
if not unit_dir.is_dir():
|
||||
continue
|
||||
|
||||
unit_id = unit_dir.name
|
||||
|
||||
for file_path in unit_dir.iterdir():
|
||||
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
|
||||
modified_time = file_path.stat().st_mtime
|
||||
photo_activities.append({
|
||||
"type": "photo",
|
||||
"timestamp": datetime.fromtimestamp(modified_time).isoformat(),
|
||||
"timestamp_unix": modified_time,
|
||||
"unit_id": unit_id,
|
||||
"filename": file_path.name,
|
||||
"photo_url": f"/api/unit/{unit_id}/photo/{file_path.name}"
|
||||
})
|
||||
|
||||
activities.extend(photo_activities)
|
||||
|
||||
# Sort all activities by timestamp (newest first)
|
||||
activities.sort(key=lambda x: x["timestamp_unix"], reverse=True)
|
||||
|
||||
# Limit to requested number
|
||||
activities = activities[:limit]
|
||||
|
||||
return {
|
||||
"activities": activities,
|
||||
"total": len(activities)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recent-callins")
|
||||
def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get recent unit call-ins (units that have reported recently).
|
||||
Returns units sorted by most recent last_seen timestamp.
|
||||
|
||||
Args:
|
||||
hours: Look back this many hours (default: 6)
|
||||
limit: Maximum number of results (default: None = all)
|
||||
"""
|
||||
# Calculate the time threshold
|
||||
time_threshold = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
|
||||
# Query emitters with recent activity, joined with roster info
|
||||
recent_emitters = db.query(Emitter)\
|
||||
.filter(Emitter.last_seen >= time_threshold)\
|
||||
.order_by(desc(Emitter.last_seen))\
|
||||
.all()
|
||||
|
||||
# Get roster info for all units
|
||||
roster_dict = {r.id: r for r in db.query(RosterUnit).all()}
|
||||
|
||||
call_ins = []
|
||||
for emitter in recent_emitters:
|
||||
roster_unit = roster_dict.get(emitter.id)
|
||||
|
||||
# Calculate time since last seen
|
||||
last_seen_utc = emitter.last_seen.replace(tzinfo=timezone.utc) if emitter.last_seen.tzinfo is None else emitter.last_seen
|
||||
time_diff = datetime.now(timezone.utc) - last_seen_utc
|
||||
|
||||
# Format time ago
|
||||
if time_diff.total_seconds() < 60:
|
||||
time_ago = "just now"
|
||||
elif time_diff.total_seconds() < 3600:
|
||||
minutes = int(time_diff.total_seconds() / 60)
|
||||
time_ago = f"{minutes}m ago"
|
||||
else:
|
||||
hours_ago = time_diff.total_seconds() / 3600
|
||||
if hours_ago < 24:
|
||||
time_ago = f"{int(hours_ago)}h {int((hours_ago % 1) * 60)}m ago"
|
||||
else:
|
||||
days = int(hours_ago / 24)
|
||||
time_ago = f"{days}d ago"
|
||||
|
||||
call_in = {
|
||||
"unit_id": emitter.id,
|
||||
"last_seen": emitter.last_seen.isoformat(),
|
||||
"time_ago": time_ago,
|
||||
"status": emitter.status,
|
||||
"device_type": roster_unit.device_type if roster_unit else "seismograph",
|
||||
"deployed": roster_unit.deployed if roster_unit else False,
|
||||
"note": roster_unit.note if roster_unit and roster_unit.note else "",
|
||||
"location": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "")
|
||||
}
|
||||
call_ins.append(call_in)
|
||||
|
||||
# Apply limit if specified
|
||||
if limit:
|
||||
call_ins = call_ins[:limit]
|
||||
|
||||
return {
|
||||
"call_ins": call_ins,
|
||||
"total": len(call_ins),
|
||||
"hours": hours,
|
||||
"time_threshold": time_threshold.isoformat()
|
||||
}
|
||||
25
app/seismo/routers/dashboard.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/dashboard/active")
|
||||
def dashboard_active(request: Request):
|
||||
snapshot = emit_status_snapshot()
|
||||
return templates.TemplateResponse(
|
||||
"partials/active_table.html",
|
||||
{"request": request, "units": snapshot["active"]}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard/benched")
|
||||
def dashboard_benched(request: Request):
|
||||
snapshot = emit_status_snapshot()
|
||||
return templates.TemplateResponse(
|
||||
"partials/benched_table.html",
|
||||
{"request": request, "units": snapshot["benched"]}
|
||||
)
|
||||
34
app/seismo/routers/dashboard_tabs.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# backend/routers/dashboard_tabs.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard-tabs"])
|
||||
|
||||
@router.get("/active")
|
||||
def get_active_units(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Return only ACTIVE (deployed) units for dashboard table swap.
|
||||
"""
|
||||
snap = emit_status_snapshot()
|
||||
units = {
|
||||
uid: u
|
||||
for uid, u in snap["units"].items()
|
||||
if u["deployed"] is True
|
||||
}
|
||||
return {"units": units}
|
||||
|
||||
@router.get("/benched")
|
||||
def get_benched_units(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Return only BENCHED (not deployed) units for dashboard table swap.
|
||||
"""
|
||||
snap = emit_status_snapshot()
|
||||
units = {
|
||||
uid: u
|
||||
for uid, u in snap["units"].items()
|
||||
if u["deployed"] is False
|
||||
}
|
||||
return {"units": units}
|
||||
242
app/seismo/routers/photos.py
Normal file
@@ -0,0 +1,242 @@
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import os
|
||||
import shutil
|
||||
from PIL import Image
|
||||
from PIL.ExifTags import TAGS, GPSTAGS
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["photos"])
|
||||
|
||||
PHOTOS_BASE_DIR = Path("data/photos")
|
||||
|
||||
|
||||
def extract_exif_data(image_path: Path) -> dict:
|
||||
"""
|
||||
Extract EXIF metadata from an image file.
|
||||
Returns dict with timestamp, GPS coordinates, and other metadata.
|
||||
"""
|
||||
try:
|
||||
image = Image.open(image_path)
|
||||
exif_data = image._getexif()
|
||||
|
||||
if not exif_data:
|
||||
return {}
|
||||
|
||||
metadata = {}
|
||||
|
||||
# Extract standard EXIF tags
|
||||
for tag_id, value in exif_data.items():
|
||||
tag = TAGS.get(tag_id, tag_id)
|
||||
|
||||
# Extract datetime
|
||||
if tag == "DateTime" or tag == "DateTimeOriginal":
|
||||
try:
|
||||
metadata["timestamp"] = datetime.strptime(str(value), "%Y:%m:%d %H:%M:%S")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Extract GPS data
|
||||
if tag == "GPSInfo":
|
||||
gps_data = {}
|
||||
for gps_tag_id in value:
|
||||
gps_tag = GPSTAGS.get(gps_tag_id, gps_tag_id)
|
||||
gps_data[gps_tag] = value[gps_tag_id]
|
||||
|
||||
# Convert GPS data to decimal degrees
|
||||
lat = gps_data.get("GPSLatitude")
|
||||
lat_ref = gps_data.get("GPSLatitudeRef")
|
||||
lon = gps_data.get("GPSLongitude")
|
||||
lon_ref = gps_data.get("GPSLongitudeRef")
|
||||
|
||||
if lat and lon and lat_ref and lon_ref:
|
||||
# Convert to decimal degrees
|
||||
lat_decimal = convert_to_degrees(lat)
|
||||
if lat_ref == "S":
|
||||
lat_decimal = -lat_decimal
|
||||
|
||||
lon_decimal = convert_to_degrees(lon)
|
||||
if lon_ref == "W":
|
||||
lon_decimal = -lon_decimal
|
||||
|
||||
metadata["latitude"] = lat_decimal
|
||||
metadata["longitude"] = lon_decimal
|
||||
metadata["coordinates"] = f"{lat_decimal},{lon_decimal}"
|
||||
|
||||
return metadata
|
||||
except Exception as e:
|
||||
print(f"Error extracting EXIF data: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def convert_to_degrees(value):
|
||||
"""
|
||||
Convert GPS coordinates from degrees/minutes/seconds to decimal degrees.
|
||||
"""
|
||||
d, m, s = value
|
||||
return float(d) + (float(m) / 60.0) + (float(s) / 3600.0)
|
||||
|
||||
|
||||
@router.post("/unit/{unit_id}/upload-photo")
|
||||
async def upload_photo(
|
||||
unit_id: str,
|
||||
photo: UploadFile = File(...),
|
||||
auto_populate_coords: bool = True,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Upload a photo for a unit and extract EXIF metadata.
|
||||
If GPS data exists and auto_populate_coords is True, update the unit's coordinates.
|
||||
"""
|
||||
# Validate file type
|
||||
allowed_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
file_ext = Path(photo.filename).suffix.lower()
|
||||
|
||||
if file_ext not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
|
||||
)
|
||||
|
||||
# Create photos directory for this unit
|
||||
unit_photo_dir = PHOTOS_BASE_DIR / unit_id
|
||||
unit_photo_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate filename with timestamp to avoid collisions
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{timestamp}_{photo.filename}"
|
||||
file_path = unit_photo_dir / filename
|
||||
|
||||
# Save the file
|
||||
try:
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(photo.file, buffer)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save photo: {str(e)}")
|
||||
|
||||
# Extract EXIF metadata
|
||||
metadata = extract_exif_data(file_path)
|
||||
|
||||
# Update unit coordinates if GPS data exists and auto_populate_coords is True
|
||||
coordinates_updated = False
|
||||
if auto_populate_coords and "coordinates" in metadata:
|
||||
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
|
||||
if roster_unit:
|
||||
roster_unit.coordinates = metadata["coordinates"]
|
||||
roster_unit.last_updated = datetime.utcnow()
|
||||
db.commit()
|
||||
coordinates_updated = True
|
||||
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"filename": filename,
|
||||
"file_path": f"/api/unit/{unit_id}/photo/{filename}",
|
||||
"metadata": {
|
||||
"timestamp": metadata.get("timestamp").isoformat() if metadata.get("timestamp") else None,
|
||||
"latitude": metadata.get("latitude"),
|
||||
"longitude": metadata.get("longitude"),
|
||||
"coordinates": metadata.get("coordinates")
|
||||
},
|
||||
"coordinates_updated": coordinates_updated
|
||||
})
|
||||
|
||||
|
||||
@router.get("/unit/{unit_id}/photos")
|
||||
def get_unit_photos(unit_id: str):
|
||||
"""
|
||||
Reads /data/photos/<unit_id>/ and returns list of image filenames.
|
||||
Primary photo = most recent file.
|
||||
"""
|
||||
unit_photo_dir = PHOTOS_BASE_DIR / unit_id
|
||||
|
||||
if not unit_photo_dir.exists():
|
||||
# Return empty list if no photos directory exists
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"photos": [],
|
||||
"primary_photo": None
|
||||
}
|
||||
|
||||
# Get all image files
|
||||
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
photos = []
|
||||
|
||||
for file_path in unit_photo_dir.iterdir():
|
||||
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
|
||||
photos.append({
|
||||
"filename": file_path.name,
|
||||
"path": f"/api/unit/{unit_id}/photo/{file_path.name}",
|
||||
"modified": file_path.stat().st_mtime
|
||||
})
|
||||
|
||||
# Sort by modification time (most recent first)
|
||||
photos.sort(key=lambda x: x["modified"], reverse=True)
|
||||
|
||||
# Primary photo is the most recent
|
||||
primary_photo = photos[0]["filename"] if photos else None
|
||||
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"photos": [p["filename"] for p in photos],
|
||||
"primary_photo": primary_photo,
|
||||
"photo_urls": [p["path"] for p in photos]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recent-photos")
|
||||
def get_recent_photos(limit: int = 12):
|
||||
"""
|
||||
Get the most recently uploaded photos across all units.
|
||||
Returns photos sorted by modification time (newest first).
|
||||
"""
|
||||
if not PHOTOS_BASE_DIR.exists():
|
||||
return {"photos": []}
|
||||
|
||||
all_photos = []
|
||||
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
|
||||
# Scan all unit directories
|
||||
for unit_dir in PHOTOS_BASE_DIR.iterdir():
|
||||
if not unit_dir.is_dir():
|
||||
continue
|
||||
|
||||
unit_id = unit_dir.name
|
||||
|
||||
# Get all photos in this unit's directory
|
||||
for file_path in unit_dir.iterdir():
|
||||
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
|
||||
all_photos.append({
|
||||
"unit_id": unit_id,
|
||||
"filename": file_path.name,
|
||||
"path": f"/api/unit/{unit_id}/photo/{file_path.name}",
|
||||
"modified": file_path.stat().st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(file_path.stat().st_mtime).isoformat()
|
||||
})
|
||||
|
||||
# Sort by modification time (most recent first) and limit
|
||||
all_photos.sort(key=lambda x: x["modified"], reverse=True)
|
||||
recent_photos = all_photos[:limit]
|
||||
|
||||
return {
|
||||
"photos": recent_photos,
|
||||
"total": len(all_photos)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/unit/{unit_id}/photo/{filename}")
|
||||
def get_photo(unit_id: str, filename: str):
|
||||
"""
|
||||
Serves a specific photo file.
|
||||
"""
|
||||
file_path = PHOTOS_BASE_DIR / unit_id / filename
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
|
||||
return FileResponse(file_path)
|
||||
46
app/seismo/routers/roster.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any
|
||||
import random
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["roster"])
|
||||
|
||||
|
||||
@router.get("/status-snapshot")
|
||||
def get_status_snapshot(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Calls emit_status_snapshot() to get current fleet status.
|
||||
This will be replaced with real Series3 emitter logic later.
|
||||
"""
|
||||
return emit_status_snapshot()
|
||||
|
||||
|
||||
@router.get("/roster")
|
||||
def get_roster(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Returns list of units with their metadata and status.
|
||||
Uses mock data for now.
|
||||
"""
|
||||
snapshot = emit_status_snapshot()
|
||||
units_list = []
|
||||
|
||||
for unit_id, unit_data in snapshot["units"].items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": unit_data["status"],
|
||||
"age": unit_data["age"],
|
||||
"last_seen": unit_data["last"],
|
||||
"deployed": unit_data["deployed"],
|
||||
"note": unit_data.get("note", ""),
|
||||
"last_file": unit_data.get("fname", "")
|
||||
})
|
||||
|
||||
# Sort by status priority (Missing > Pending > OK) then by ID
|
||||
status_priority = {"Missing": 0, "Pending": 1, "OK": 2}
|
||||
units_list.sort(key=lambda x: (status_priority.get(x["status"], 3), x["id"]))
|
||||
|
||||
return {"units": units_list}
|
||||
720
app/seismo/routers/roster_edit.py
Normal file
@@ -0,0 +1,720 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, date
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import httpx
|
||||
import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
||||
|
||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SLMM backend URL for syncing device configs to cache
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
|
||||
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
||||
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
|
||||
"""Helper function to record a change in unit history"""
|
||||
history_entry = UnitHistory(
|
||||
unit_id=unit_id,
|
||||
change_type=change_type,
|
||||
field_name=field_name,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
changed_at=datetime.utcnow(),
|
||||
source=source,
|
||||
notes=notes
|
||||
)
|
||||
db.add(history_entry)
|
||||
# Note: caller is responsible for db.commit()
|
||||
|
||||
|
||||
def get_or_create_roster_unit(db: Session, unit_id: str):
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not unit:
|
||||
unit = RosterUnit(id=unit_id)
|
||||
db.add(unit)
|
||||
db.commit()
|
||||
db.refresh(unit)
|
||||
return unit
|
||||
|
||||
|
||||
async def sync_slm_to_slmm_cache(
|
||||
unit_id: str,
|
||||
host: str = None,
|
||||
tcp_port: int = None,
|
||||
ftp_port: int = None,
|
||||
ftp_username: str = None,
|
||||
ftp_password: str = None,
|
||||
deployed_with_modem_id: str = None,
|
||||
db: Session = None
|
||||
) -> dict:
|
||||
"""
|
||||
Sync SLM device configuration to SLMM backend cache.
|
||||
|
||||
Terra-View is the source of truth for device configs. This function updates
|
||||
SLMM's config cache (NL43Config table) so SLMM can look up device connection
|
||||
info by unit_id without Terra-View passing host:port with every request.
|
||||
|
||||
Args:
|
||||
unit_id: Unique identifier for the SLM device
|
||||
host: Direct IP address/hostname OR will be resolved from modem
|
||||
tcp_port: TCP control port (default: 2255)
|
||||
ftp_port: FTP port (default: 21)
|
||||
ftp_username: FTP username (optional)
|
||||
ftp_password: FTP password (optional)
|
||||
deployed_with_modem_id: If set, resolve modem IP as host
|
||||
db: Database session for modem lookup
|
||||
|
||||
Returns:
|
||||
dict: {"success": bool, "message": str}
|
||||
"""
|
||||
# Resolve host from modem if assigned
|
||||
if deployed_with_modem_id and db:
|
||||
modem = db.query(RosterUnit).filter_by(
|
||||
id=deployed_with_modem_id,
|
||||
device_type="modem"
|
||||
).first()
|
||||
if modem and modem.ip_address:
|
||||
host = modem.ip_address
|
||||
logger.info(f"Resolved host from modem {deployed_with_modem_id}: {host}")
|
||||
|
||||
# Validate required fields
|
||||
if not host:
|
||||
logger.warning(f"Cannot sync SLM {unit_id} to SLMM: no host/IP address provided")
|
||||
return {"success": False, "message": "No host IP address available"}
|
||||
|
||||
# Set defaults
|
||||
tcp_port = tcp_port or 2255
|
||||
ftp_port = ftp_port or 21
|
||||
|
||||
# Build SLMM cache payload
|
||||
config_payload = {
|
||||
"host": host,
|
||||
"tcp_port": tcp_port,
|
||||
"tcp_enabled": True,
|
||||
"ftp_enabled": bool(ftp_username and ftp_password),
|
||||
"web_enabled": False
|
||||
}
|
||||
|
||||
if ftp_username and ftp_password:
|
||||
config_payload["ftp_username"] = ftp_username
|
||||
config_payload["ftp_password"] = ftp_password
|
||||
|
||||
# Call SLMM cache update API
|
||||
slmm_url = f"{SLMM_BASE_URL}/api/nl43/{unit_id}/config"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.put(slmm_url, json=config_payload)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"Successfully synced SLM {unit_id} to SLMM cache")
|
||||
return {"success": True, "message": "Device config cached in SLMM"}
|
||||
else:
|
||||
logger.error(f"SLMM cache sync failed for {unit_id}: HTTP {response.status_code}")
|
||||
return {"success": False, "message": f"SLMM returned status {response.status_code}"}
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.error(f"Cannot connect to SLMM service at {SLMM_BASE_URL}")
|
||||
return {"success": False, "message": "SLMM service unavailable"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing SLM {unit_id} to SLMM: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
async def add_roster_unit(
|
||||
id: str = Form(...),
|
||||
device_type: str = Form("seismograph"),
|
||||
unit_type: str = Form("series3"),
|
||||
deployed: str = Form(None),
|
||||
retired: str = Form(None),
|
||||
note: str = Form(""),
|
||||
project_id: str = Form(None),
|
||||
location: str = Form(None),
|
||||
address: str = Form(None),
|
||||
coordinates: str = Form(None),
|
||||
# Seismograph-specific fields
|
||||
last_calibrated: str = Form(None),
|
||||
next_calibration_due: str = Form(None),
|
||||
deployed_with_modem_id: str = Form(None),
|
||||
# Modem-specific fields
|
||||
ip_address: str = Form(None),
|
||||
phone_number: str = Form(None),
|
||||
hardware_model: str = Form(None),
|
||||
# Sound Level Meter-specific fields
|
||||
slm_host: str = Form(None),
|
||||
slm_tcp_port: str = Form(None),
|
||||
slm_ftp_port: str = Form(None),
|
||||
slm_model: str = Form(None),
|
||||
slm_serial_number: str = Form(None),
|
||||
slm_frequency_weighting: str = Form(None),
|
||||
slm_time_weighting: str = Form(None),
|
||||
slm_measurement_range: str = Form(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
logger.info(f"Adding unit: id={id}, device_type={device_type}, deployed={deployed}, retired={retired}")
|
||||
|
||||
# Convert boolean strings to actual booleans
|
||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||
|
||||
# Convert port strings to integers
|
||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
||||
|
||||
if db.query(RosterUnit).filter(RosterUnit.id == id).first():
|
||||
raise HTTPException(status_code=400, detail="Unit already exists")
|
||||
|
||||
# Parse date fields if provided
|
||||
last_cal_date = None
|
||||
if last_calibrated:
|
||||
try:
|
||||
last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
|
||||
|
||||
next_cal_date = None
|
||||
if next_calibration_due:
|
||||
try:
|
||||
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD")
|
||||
|
||||
unit = RosterUnit(
|
||||
id=id,
|
||||
device_type=device_type,
|
||||
unit_type=unit_type,
|
||||
deployed=deployed_bool,
|
||||
retired=retired_bool,
|
||||
note=note,
|
||||
project_id=project_id,
|
||||
location=location,
|
||||
address=address,
|
||||
coordinates=coordinates,
|
||||
last_updated=datetime.utcnow(),
|
||||
# Seismograph-specific fields
|
||||
last_calibrated=last_cal_date,
|
||||
next_calibration_due=next_cal_date,
|
||||
deployed_with_modem_id=deployed_with_modem_id if deployed_with_modem_id else None,
|
||||
# Modem-specific fields
|
||||
ip_address=ip_address if ip_address else None,
|
||||
phone_number=phone_number if phone_number else None,
|
||||
hardware_model=hardware_model if hardware_model else None,
|
||||
# Sound Level Meter-specific fields
|
||||
slm_host=slm_host if slm_host else None,
|
||||
slm_tcp_port=slm_tcp_port_int,
|
||||
slm_ftp_port=slm_ftp_port_int,
|
||||
slm_model=slm_model if slm_model else None,
|
||||
slm_serial_number=slm_serial_number if slm_serial_number else None,
|
||||
slm_frequency_weighting=slm_frequency_weighting if slm_frequency_weighting else None,
|
||||
slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
|
||||
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
||||
)
|
||||
db.add(unit)
|
||||
db.commit()
|
||||
|
||||
# If sound level meter, sync config to SLMM cache
|
||||
if device_type == "sound_level_meter":
|
||||
logger.info(f"Syncing SLM {id} config to SLMM cache...")
|
||||
result = await sync_slm_to_slmm_cache(
|
||||
unit_id=id,
|
||||
host=slm_host,
|
||||
tcp_port=slm_tcp_port_int,
|
||||
ftp_port=slm_ftp_port_int,
|
||||
deployed_with_modem_id=deployed_with_modem_id,
|
||||
db=db
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(f"SLMM cache sync warning for {id}: {result['message']}")
|
||||
# Don't fail the operation - device is still added to Terra-View roster
|
||||
# User can manually sync later or SLMM will be synced on next config update
|
||||
|
||||
return {"message": "Unit added", "id": id, "device_type": device_type}
|
||||
|
||||
|
||||
@router.get("/modems")
|
||||
def get_modems_list(db: Session = Depends(get_db)):
|
||||
"""Get list of all modem units for dropdown selection"""
|
||||
modems = db.query(RosterUnit).filter_by(device_type="modem", retired=False).order_by(RosterUnit.id).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": modem.id,
|
||||
"ip_address": modem.ip_address,
|
||||
"phone_number": modem.phone_number,
|
||||
"hardware_model": modem.hardware_model,
|
||||
"deployed": modem.deployed
|
||||
}
|
||||
for modem in modems
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{unit_id}")
|
||||
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Get a single roster unit by ID"""
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
|
||||
return {
|
||||
"id": unit.id,
|
||||
"device_type": unit.device_type or "seismograph",
|
||||
"unit_type": unit.unit_type,
|
||||
"deployed": unit.deployed,
|
||||
"retired": unit.retired,
|
||||
"note": unit.note or "",
|
||||
"project_id": unit.project_id or "",
|
||||
"location": unit.location or "",
|
||||
"address": unit.address or "",
|
||||
"coordinates": unit.coordinates or "",
|
||||
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
|
||||
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
|
||||
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||
"ip_address": unit.ip_address or "",
|
||||
"phone_number": unit.phone_number or "",
|
||||
"hardware_model": unit.hardware_model or "",
|
||||
"slm_host": unit.slm_host or "",
|
||||
"slm_tcp_port": unit.slm_tcp_port or "",
|
||||
"slm_ftp_port": unit.slm_ftp_port or "",
|
||||
"slm_model": unit.slm_model or "",
|
||||
"slm_serial_number": unit.slm_serial_number or "",
|
||||
"slm_frequency_weighting": unit.slm_frequency_weighting or "",
|
||||
"slm_time_weighting": unit.slm_time_weighting or "",
|
||||
"slm_measurement_range": unit.slm_measurement_range or "",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/edit/{unit_id}")
|
||||
def edit_roster_unit(
|
||||
unit_id: str,
|
||||
device_type: str = Form("seismograph"),
|
||||
unit_type: str = Form("series3"),
|
||||
deployed: str = Form(None),
|
||||
retired: str = Form(None),
|
||||
note: str = Form(""),
|
||||
project_id: str = Form(None),
|
||||
location: str = Form(None),
|
||||
address: str = Form(None),
|
||||
coordinates: str = Form(None),
|
||||
# Seismograph-specific fields
|
||||
last_calibrated: str = Form(None),
|
||||
next_calibration_due: str = Form(None),
|
||||
deployed_with_modem_id: str = Form(None),
|
||||
# Modem-specific fields
|
||||
ip_address: str = Form(None),
|
||||
phone_number: str = Form(None),
|
||||
hardware_model: str = Form(None),
|
||||
# Sound Level Meter-specific fields
|
||||
slm_host: str = Form(None),
|
||||
slm_tcp_port: str = Form(None),
|
||||
slm_ftp_port: str = Form(None),
|
||||
slm_model: str = Form(None),
|
||||
slm_serial_number: str = Form(None),
|
||||
slm_frequency_weighting: str = Form(None),
|
||||
slm_time_weighting: str = Form(None),
|
||||
slm_measurement_range: str = Form(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
|
||||
# Convert boolean strings to actual booleans
|
||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||
|
||||
# Convert port strings to integers
|
||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
||||
|
||||
# Parse date fields if provided
|
||||
last_cal_date = None
|
||||
if last_calibrated:
|
||||
try:
|
||||
last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
|
||||
|
||||
next_cal_date = None
|
||||
if next_calibration_due:
|
||||
try:
|
||||
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD")
|
||||
|
||||
# Track changes for history
|
||||
old_note = unit.note
|
||||
old_deployed = unit.deployed
|
||||
old_retired = unit.retired
|
||||
|
||||
# Update all fields
|
||||
unit.device_type = device_type
|
||||
unit.unit_type = unit_type
|
||||
unit.deployed = deployed_bool
|
||||
unit.retired = retired_bool
|
||||
unit.note = note
|
||||
unit.project_id = project_id
|
||||
unit.location = location
|
||||
unit.address = address
|
||||
unit.coordinates = coordinates
|
||||
unit.last_updated = datetime.utcnow()
|
||||
|
||||
# Seismograph-specific fields
|
||||
unit.last_calibrated = last_cal_date
|
||||
unit.next_calibration_due = next_cal_date
|
||||
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
||||
|
||||
# Modem-specific fields
|
||||
unit.ip_address = ip_address if ip_address else None
|
||||
unit.phone_number = phone_number if phone_number else None
|
||||
unit.hardware_model = hardware_model if hardware_model else None
|
||||
|
||||
# Sound Level Meter-specific fields
|
||||
unit.slm_host = slm_host if slm_host else None
|
||||
unit.slm_tcp_port = slm_tcp_port_int
|
||||
unit.slm_ftp_port = slm_ftp_port_int
|
||||
unit.slm_model = slm_model if slm_model else None
|
||||
unit.slm_serial_number = slm_serial_number if slm_serial_number else None
|
||||
unit.slm_frequency_weighting = slm_frequency_weighting if slm_frequency_weighting else None
|
||||
unit.slm_time_weighting = slm_time_weighting if slm_time_weighting else None
|
||||
unit.slm_measurement_range = slm_measurement_range if slm_measurement_range else None
|
||||
|
||||
# Record history entries for changed fields
|
||||
if old_note != note:
|
||||
record_history(db, unit_id, "note_change", "note", old_note, note, "manual")
|
||||
|
||||
if old_deployed != deployed:
|
||||
status_text = "deployed" if deployed else "benched"
|
||||
old_status_text = "deployed" if old_deployed else "benched"
|
||||
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
|
||||
|
||||
if old_retired != retired:
|
||||
status_text = "retired" if retired else "active"
|
||||
old_status_text = "retired" if old_retired else "active"
|
||||
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
||||
|
||||
db.commit()
|
||||
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||
|
||||
|
||||
@router.post("/set-deployed/{unit_id}")
|
||||
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
||||
unit = get_or_create_roster_unit(db, unit_id)
|
||||
old_deployed = unit.deployed
|
||||
unit.deployed = deployed
|
||||
unit.last_updated = datetime.utcnow()
|
||||
|
||||
# Record history entry for deployed status change
|
||||
if old_deployed != deployed:
|
||||
status_text = "deployed" if deployed else "benched"
|
||||
old_status_text = "deployed" if old_deployed else "benched"
|
||||
record_history(
|
||||
db=db,
|
||||
unit_id=unit_id,
|
||||
change_type="deployed_change",
|
||||
field_name="deployed",
|
||||
old_value=old_status_text,
|
||||
new_value=status_text,
|
||||
source="manual"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return {"message": "Updated", "id": unit_id, "deployed": deployed}
|
||||
|
||||
|
||||
@router.post("/set-retired/{unit_id}")
|
||||
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
||||
unit = get_or_create_roster_unit(db, unit_id)
|
||||
old_retired = unit.retired
|
||||
unit.retired = retired
|
||||
unit.last_updated = datetime.utcnow()
|
||||
|
||||
# Record history entry for retired status change
|
||||
if old_retired != retired:
|
||||
status_text = "retired" if retired else "active"
|
||||
old_status_text = "retired" if old_retired else "active"
|
||||
record_history(
|
||||
db=db,
|
||||
unit_id=unit_id,
|
||||
change_type="retired_change",
|
||||
field_name="retired",
|
||||
old_value=old_status_text,
|
||||
new_value=status_text,
|
||||
source="manual"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return {"message": "Updated", "id": unit_id, "retired": retired}
|
||||
|
||||
|
||||
@router.delete("/{unit_id}")
|
||||
def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Permanently delete a unit from the database.
|
||||
Checks roster, emitters, and ignored_units tables and deletes from any table where the unit exists.
|
||||
"""
|
||||
deleted = False
|
||||
|
||||
# Try to delete from roster table
|
||||
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if roster_unit:
|
||||
db.delete(roster_unit)
|
||||
deleted = True
|
||||
|
||||
# Try to delete from emitters table
|
||||
emitter = db.query(Emitter).filter(Emitter.id == unit_id).first()
|
||||
if emitter:
|
||||
db.delete(emitter)
|
||||
deleted = True
|
||||
|
||||
# Try to delete from ignored_units table
|
||||
ignored_unit = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first()
|
||||
if ignored_unit:
|
||||
db.delete(ignored_unit)
|
||||
deleted = True
|
||||
|
||||
# If not found in any table, return error
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
|
||||
db.commit()
|
||||
return {"message": "Unit deleted", "id": unit_id}
|
||||
|
||||
|
||||
@router.post("/set-note/{unit_id}")
|
||||
def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
|
||||
unit = get_or_create_roster_unit(db, unit_id)
|
||||
old_note = unit.note
|
||||
unit.note = note
|
||||
unit.last_updated = datetime.utcnow()
|
||||
|
||||
# Record history entry for note change
|
||||
if old_note != note:
|
||||
record_history(
|
||||
db=db,
|
||||
unit_id=unit_id,
|
||||
change_type="note_change",
|
||||
field_name="note",
|
||||
old_value=old_note,
|
||||
new_value=note,
|
||||
source="manual"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return {"message": "Updated", "id": unit_id, "note": note}
|
||||
|
||||
|
||||
@router.post("/import-csv")
|
||||
async def import_csv(
|
||||
file: UploadFile = File(...),
|
||||
update_existing: bool = Form(True),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Import roster units from CSV file.
|
||||
|
||||
Expected CSV columns (unit_id is required, others are optional):
|
||||
- unit_id: Unique identifier for the unit
|
||||
- unit_type: Type of unit (default: "series3")
|
||||
- deployed: Boolean for deployment status (default: False)
|
||||
- retired: Boolean for retirement status (default: False)
|
||||
- note: Notes about the unit
|
||||
- project_id: Project identifier
|
||||
- location: Location description
|
||||
|
||||
Args:
|
||||
file: CSV file upload
|
||||
update_existing: If True, update existing units; if False, skip them
|
||||
"""
|
||||
|
||||
if not file.filename.endswith('.csv'):
|
||||
raise HTTPException(status_code=400, detail="File must be a CSV")
|
||||
|
||||
# Read file content
|
||||
contents = await file.read()
|
||||
csv_text = contents.decode('utf-8')
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||
|
||||
results = {
|
||||
"added": [],
|
||||
"updated": [],
|
||||
"skipped": [],
|
||||
"errors": []
|
||||
}
|
||||
|
||||
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 to account for header
|
||||
try:
|
||||
# Validate required field
|
||||
unit_id = row.get('unit_id', '').strip()
|
||||
if not unit_id:
|
||||
results["errors"].append({
|
||||
"row": row_num,
|
||||
"error": "Missing required field: unit_id"
|
||||
})
|
||||
continue
|
||||
|
||||
# Check if unit exists
|
||||
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
|
||||
if existing_unit:
|
||||
if not update_existing:
|
||||
results["skipped"].append(unit_id)
|
||||
continue
|
||||
|
||||
# Update existing unit
|
||||
existing_unit.unit_type = row.get('unit_type', existing_unit.unit_type or 'series3')
|
||||
existing_unit.deployed = row.get('deployed', '').lower() in ('true', '1', 'yes') if row.get('deployed') else existing_unit.deployed
|
||||
existing_unit.retired = row.get('retired', '').lower() in ('true', '1', 'yes') if row.get('retired') else existing_unit.retired
|
||||
existing_unit.note = row.get('note', existing_unit.note or '')
|
||||
existing_unit.project_id = row.get('project_id', existing_unit.project_id)
|
||||
existing_unit.location = row.get('location', existing_unit.location)
|
||||
existing_unit.address = row.get('address', existing_unit.address)
|
||||
existing_unit.coordinates = row.get('coordinates', existing_unit.coordinates)
|
||||
existing_unit.last_updated = datetime.utcnow()
|
||||
|
||||
results["updated"].append(unit_id)
|
||||
else:
|
||||
# Create new unit
|
||||
new_unit = RosterUnit(
|
||||
id=unit_id,
|
||||
unit_type=row.get('unit_type', 'series3'),
|
||||
deployed=row.get('deployed', '').lower() in ('true', '1', 'yes'),
|
||||
retired=row.get('retired', '').lower() in ('true', '1', 'yes'),
|
||||
note=row.get('note', ''),
|
||||
project_id=row.get('project_id'),
|
||||
location=row.get('location'),
|
||||
address=row.get('address'),
|
||||
coordinates=row.get('coordinates'),
|
||||
last_updated=datetime.utcnow()
|
||||
)
|
||||
db.add(new_unit)
|
||||
results["added"].append(unit_id)
|
||||
|
||||
except Exception as e:
|
||||
results["errors"].append({
|
||||
"row": row_num,
|
||||
"unit_id": row.get('unit_id', 'unknown'),
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
# Commit all changes
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||
|
||||
return {
|
||||
"message": "CSV import completed",
|
||||
"summary": {
|
||||
"added": len(results["added"]),
|
||||
"updated": len(results["updated"]),
|
||||
"skipped": len(results["skipped"]),
|
||||
"errors": len(results["errors"])
|
||||
},
|
||||
"details": results
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ignore/{unit_id}")
|
||||
def ignore_unit(unit_id: str, reason: str = Form(""), db: Session = Depends(get_db)):
|
||||
"""
|
||||
Add a unit to the ignore list to suppress it from unknown emitters.
|
||||
"""
|
||||
# Check if already ignored
|
||||
if db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first():
|
||||
raise HTTPException(status_code=400, detail="Unit already ignored")
|
||||
|
||||
ignored = IgnoredUnit(
|
||||
id=unit_id,
|
||||
reason=reason,
|
||||
ignored_at=datetime.utcnow()
|
||||
)
|
||||
db.add(ignored)
|
||||
db.commit()
|
||||
return {"message": "Unit ignored", "id": unit_id}
|
||||
|
||||
|
||||
@router.delete("/ignore/{unit_id}")
|
||||
def unignore_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Remove a unit from the ignore list.
|
||||
"""
|
||||
ignored = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first()
|
||||
if not ignored:
|
||||
raise HTTPException(status_code=404, detail="Unit not in ignore list")
|
||||
|
||||
db.delete(ignored)
|
||||
db.commit()
|
||||
return {"message": "Unit unignored", "id": unit_id}
|
||||
|
||||
|
||||
@router.get("/ignored")
|
||||
def list_ignored_units(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get list of all ignored units.
|
||||
"""
|
||||
ignored_units = db.query(IgnoredUnit).all()
|
||||
return {
|
||||
"ignored": [
|
||||
{
|
||||
"id": unit.id,
|
||||
"reason": unit.reason,
|
||||
"ignored_at": unit.ignored_at.isoformat()
|
||||
}
|
||||
for unit in ignored_units
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/history/{unit_id}")
|
||||
def get_unit_history(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get complete history timeline for a unit.
|
||||
Returns all historical changes ordered by most recent first.
|
||||
"""
|
||||
history_entries = db.query(UnitHistory).filter(
|
||||
UnitHistory.unit_id == unit_id
|
||||
).order_by(UnitHistory.changed_at.desc()).all()
|
||||
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"history": [
|
||||
{
|
||||
"id": entry.id,
|
||||
"change_type": entry.change_type,
|
||||
"field_name": entry.field_name,
|
||||
"old_value": entry.old_value,
|
||||
"new_value": entry.new_value,
|
||||
"changed_at": entry.changed_at.isoformat(),
|
||||
"source": entry.source,
|
||||
"notes": entry.notes
|
||||
}
|
||||
for entry in history_entries
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/history/{history_id}")
|
||||
def delete_history_entry(history_id: int, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Delete a specific history entry by ID.
|
||||
Allows manual cleanup of old history entries.
|
||||
"""
|
||||
history_entry = db.query(UnitHistory).filter(UnitHistory.id == history_id).first()
|
||||
if not history_entry:
|
||||
raise HTTPException(status_code=404, detail="History entry not found")
|
||||
|
||||
db.delete(history_entry)
|
||||
db.commit()
|
||||
return {"message": "History entry deleted", "id": history_id}
|
||||
81
app/seismo/routers/seismo_dashboard.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Seismograph Dashboard API Router
|
||||
Provides endpoints for the seismograph-specific dashboard
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
|
||||
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/stats", response_class=HTMLResponse)
|
||||
async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Returns HTML partial with seismograph statistics summary
|
||||
"""
|
||||
# Get all seismograph units
|
||||
seismos = db.query(RosterUnit).filter_by(
|
||||
device_type="seismograph",
|
||||
retired=False
|
||||
).all()
|
||||
|
||||
total = len(seismos)
|
||||
deployed = sum(1 for s in seismos if s.deployed)
|
||||
benched = sum(1 for s in seismos if not s.deployed)
|
||||
|
||||
# Count modems assigned to deployed seismographs
|
||||
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
||||
without_modem = deployed - with_modem
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/seismo_stats.html",
|
||||
{
|
||||
"request": request,
|
||||
"total": total,
|
||||
"deployed": deployed,
|
||||
"benched": benched,
|
||||
"with_modem": with_modem,
|
||||
"without_modem": without_modem
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/units", response_class=HTMLResponse)
|
||||
async def get_seismo_units(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
search: str = Query(None)
|
||||
):
|
||||
"""
|
||||
Returns HTML partial with filterable seismograph unit list
|
||||
"""
|
||||
query = db.query(RosterUnit).filter_by(
|
||||
device_type="seismograph",
|
||||
retired=False
|
||||
)
|
||||
|
||||
# Apply search filter
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
query = query.filter(
|
||||
(RosterUnit.id.ilike(f"%{search}%")) |
|
||||
(RosterUnit.note.ilike(f"%{search}%")) |
|
||||
(RosterUnit.address.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
seismos = query.order_by(RosterUnit.id).all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/seismo_unit_list.html",
|
||||
{
|
||||
"request": request,
|
||||
"units": seismos,
|
||||
"search": search or ""
|
||||
}
|
||||
)
|
||||
479
app/seismo/routers/settings.py
Normal file
@@ -0,0 +1,479 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, date
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import csv
|
||||
import io
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||
from backend.services.database_backup import DatabaseBackupService
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
@router.get("/export-csv")
|
||||
def export_roster_csv(db: Session = Depends(get_db)):
|
||||
"""Export all roster units to CSV"""
|
||||
units = db.query(RosterUnit).all()
|
||||
|
||||
# Create CSV in memory
|
||||
output = io.StringIO()
|
||||
fieldnames = [
|
||||
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
|
||||
'note', 'project_id', 'location', 'address', 'coordinates',
|
||||
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
|
||||
'ip_address', 'phone_number', 'hardware_model'
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for unit in units:
|
||||
writer.writerow({
|
||||
'unit_id': unit.id,
|
||||
'unit_type': unit.unit_type or '',
|
||||
'device_type': unit.device_type or 'seismograph',
|
||||
'deployed': 'true' if unit.deployed else 'false',
|
||||
'retired': 'true' if unit.retired else 'false',
|
||||
'note': unit.note or '',
|
||||
'project_id': unit.project_id or '',
|
||||
'location': unit.location or '',
|
||||
'address': unit.address or '',
|
||||
'coordinates': unit.coordinates or '',
|
||||
'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
|
||||
'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '',
|
||||
'deployed_with_modem_id': unit.deployed_with_modem_id or '',
|
||||
'ip_address': unit.ip_address or '',
|
||||
'phone_number': unit.phone_number or '',
|
||||
'hardware_model': unit.hardware_model or ''
|
||||
})
|
||||
|
||||
output.seek(0)
|
||||
filename = f"roster_export_{date.today().isoformat()}.csv"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(output.getvalue().encode('utf-8')),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_table_stats(db: Session = Depends(get_db)):
|
||||
"""Get counts for all tables"""
|
||||
roster_count = db.query(RosterUnit).count()
|
||||
emitters_count = db.query(Emitter).count()
|
||||
ignored_count = db.query(IgnoredUnit).count()
|
||||
|
||||
return {
|
||||
"roster": roster_count,
|
||||
"emitters": emitters_count,
|
||||
"ignored": ignored_count,
|
||||
"total": roster_count + emitters_count + ignored_count
|
||||
}
|
||||
|
||||
|
||||
@router.get("/roster-units")
|
||||
def get_all_roster_units(db: Session = Depends(get_db)):
|
||||
"""Get all roster units for management table"""
|
||||
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
|
||||
|
||||
return [{
|
||||
"id": unit.id,
|
||||
"device_type": unit.device_type or "seismograph",
|
||||
"unit_type": unit.unit_type or "series3",
|
||||
"deployed": unit.deployed,
|
||||
"retired": unit.retired,
|
||||
"note": unit.note or "",
|
||||
"project_id": unit.project_id or "",
|
||||
"location": unit.location or "",
|
||||
"address": unit.address or "",
|
||||
"coordinates": unit.coordinates or "",
|
||||
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
||||
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
|
||||
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||
"ip_address": unit.ip_address or "",
|
||||
"phone_number": unit.phone_number or "",
|
||||
"hardware_model": unit.hardware_model or "",
|
||||
"slm_host": unit.slm_host or "",
|
||||
"slm_tcp_port": unit.slm_tcp_port,
|
||||
"slm_model": unit.slm_model or "",
|
||||
"slm_serial_number": unit.slm_serial_number or "",
|
||||
"slm_frequency_weighting": unit.slm_frequency_weighting or "",
|
||||
"slm_time_weighting": unit.slm_time_weighting or "",
|
||||
"slm_measurement_range": unit.slm_measurement_range or "",
|
||||
"slm_last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||
"last_updated": unit.last_updated.isoformat() if unit.last_updated else None
|
||||
} for unit in units]
|
||||
|
||||
|
||||
def parse_date(date_str):
|
||||
"""Helper function to parse date strings"""
|
||||
if not date_str or not date_str.strip():
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(date_str.strip(), "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/import-csv-replace")
|
||||
async def import_csv_replace(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Replace all roster data with CSV import (atomic transaction).
|
||||
Clears roster table first, then imports all rows from CSV.
|
||||
"""
|
||||
|
||||
if not file.filename.endswith('.csv'):
|
||||
raise HTTPException(status_code=400, detail="File must be a CSV")
|
||||
|
||||
# Read and parse CSV
|
||||
contents = await file.read()
|
||||
csv_text = contents.decode('utf-8')
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||
|
||||
# Parse all rows FIRST (fail fast before deletion)
|
||||
parsed_units = []
|
||||
for row_num, row in enumerate(csv_reader, start=2):
|
||||
unit_id = row.get('unit_id', '').strip()
|
||||
if not unit_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Row {row_num}: Missing required field unit_id"
|
||||
)
|
||||
|
||||
# Parse and validate dates
|
||||
last_cal_date = parse_date(row.get('last_calibrated'))
|
||||
next_cal_date = parse_date(row.get('next_calibration_due'))
|
||||
|
||||
parsed_units.append({
|
||||
'id': unit_id,
|
||||
'unit_type': row.get('unit_type', 'series3'),
|
||||
'device_type': row.get('device_type', 'seismograph'),
|
||||
'deployed': row.get('deployed', '').lower() in ('true', '1', 'yes'),
|
||||
'retired': row.get('retired', '').lower() in ('true', '1', 'yes'),
|
||||
'note': row.get('note', ''),
|
||||
'project_id': row.get('project_id') or None,
|
||||
'location': row.get('location') or None,
|
||||
'address': row.get('address') or None,
|
||||
'coordinates': row.get('coordinates') or None,
|
||||
'last_calibrated': last_cal_date,
|
||||
'next_calibration_due': next_cal_date,
|
||||
'deployed_with_modem_id': row.get('deployed_with_modem_id') or None,
|
||||
'ip_address': row.get('ip_address') or None,
|
||||
'phone_number': row.get('phone_number') or None,
|
||||
'hardware_model': row.get('hardware_model') or None,
|
||||
})
|
||||
|
||||
# Atomic transaction: delete all, then insert all
|
||||
try:
|
||||
deleted_count = db.query(RosterUnit).delete()
|
||||
|
||||
for unit_data in parsed_units:
|
||||
new_unit = RosterUnit(**unit_data, last_updated=datetime.utcnow())
|
||||
db.add(new_unit)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Roster replaced successfully",
|
||||
"deleted": deleted_count,
|
||||
"added": len(parsed_units)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/clear-all")
|
||||
def clear_all_data(db: Session = Depends(get_db)):
|
||||
"""Clear all tables (roster, emitters, ignored)"""
|
||||
try:
|
||||
roster_count = db.query(RosterUnit).delete()
|
||||
emitters_count = db.query(Emitter).delete()
|
||||
ignored_count = db.query(IgnoredUnit).delete()
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "All data cleared",
|
||||
"deleted": {
|
||||
"roster": roster_count,
|
||||
"emitters": emitters_count,
|
||||
"ignored": ignored_count,
|
||||
"total": roster_count + emitters_count + ignored_count
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/clear-roster")
|
||||
def clear_roster(db: Session = Depends(get_db)):
|
||||
"""Clear roster table only"""
|
||||
try:
|
||||
count = db.query(RosterUnit).delete()
|
||||
db.commit()
|
||||
return {"message": "Roster cleared", "deleted": count}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/clear-emitters")
|
||||
def clear_emitters(db: Session = Depends(get_db)):
|
||||
"""Clear emitters table only"""
|
||||
try:
|
||||
count = db.query(Emitter).delete()
|
||||
db.commit()
|
||||
return {"message": "Emitters cleared", "deleted": count}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/clear-ignored")
|
||||
def clear_ignored(db: Session = Depends(get_db)):
|
||||
"""Clear ignored units table only"""
|
||||
try:
|
||||
count = db.query(IgnoredUnit).delete()
|
||||
db.commit()
|
||||
return {"message": "Ignored units cleared", "deleted": count}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
||||
|
||||
|
||||
# User Preferences Endpoints
|
||||
|
||||
class PreferencesUpdate(BaseModel):
|
||||
"""Schema for updating user preferences (all fields optional)"""
|
||||
timezone: Optional[str] = None
|
||||
theme: Optional[str] = None
|
||||
auto_refresh_interval: Optional[int] = None
|
||||
date_format: Optional[str] = None
|
||||
table_rows_per_page: Optional[int] = None
|
||||
calibration_interval_days: Optional[int] = None
|
||||
calibration_warning_days: Optional[int] = None
|
||||
status_ok_threshold_hours: Optional[int] = None
|
||||
status_pending_threshold_hours: Optional[int] = None
|
||||
|
||||
|
||||
@router.get("/preferences")
|
||||
def get_preferences(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get user preferences. Creates default preferences if none exist.
|
||||
"""
|
||||
prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first()
|
||||
|
||||
if not prefs:
|
||||
# Create default preferences
|
||||
prefs = UserPreferences(id=1)
|
||||
db.add(prefs)
|
||||
db.commit()
|
||||
db.refresh(prefs)
|
||||
|
||||
return {
|
||||
"timezone": prefs.timezone,
|
||||
"theme": prefs.theme,
|
||||
"auto_refresh_interval": prefs.auto_refresh_interval,
|
||||
"date_format": prefs.date_format,
|
||||
"table_rows_per_page": prefs.table_rows_per_page,
|
||||
"calibration_interval_days": prefs.calibration_interval_days,
|
||||
"calibration_warning_days": prefs.calibration_warning_days,
|
||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
@router.put("/preferences")
|
||||
def update_preferences(
|
||||
updates: PreferencesUpdate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update user preferences. Accepts partial updates.
|
||||
Creates default preferences if none exist.
|
||||
"""
|
||||
prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first()
|
||||
|
||||
if not prefs:
|
||||
# Create default preferences
|
||||
prefs = UserPreferences(id=1)
|
||||
db.add(prefs)
|
||||
|
||||
# Update only provided fields
|
||||
update_data = updates.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(prefs, field, value)
|
||||
|
||||
prefs.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(prefs)
|
||||
|
||||
return {
|
||||
"message": "Preferences updated successfully",
|
||||
"timezone": prefs.timezone,
|
||||
"theme": prefs.theme,
|
||||
"auto_refresh_interval": prefs.auto_refresh_interval,
|
||||
"date_format": prefs.date_format,
|
||||
"table_rows_per_page": prefs.table_rows_per_page,
|
||||
"calibration_interval_days": prefs.calibration_interval_days,
|
||||
"calibration_warning_days": prefs.calibration_warning_days,
|
||||
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
# Database Management Endpoints
|
||||
|
||||
backup_service = DatabaseBackupService()
|
||||
|
||||
|
||||
@router.get("/database/stats")
|
||||
def get_database_stats():
|
||||
"""Get current database statistics"""
|
||||
try:
|
||||
stats = backup_service.get_database_stats()
|
||||
return stats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get database stats: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/database/snapshot")
|
||||
def create_database_snapshot(description: Optional[str] = None):
|
||||
"""Create a full database snapshot"""
|
||||
try:
|
||||
snapshot = backup_service.create_snapshot(description=description)
|
||||
return {
|
||||
"message": "Snapshot created successfully",
|
||||
"snapshot": snapshot
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Snapshot creation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/database/snapshots")
|
||||
def list_database_snapshots():
|
||||
"""List all available database snapshots"""
|
||||
try:
|
||||
snapshots = backup_service.list_snapshots()
|
||||
return {
|
||||
"snapshots": snapshots,
|
||||
"count": len(snapshots)
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list snapshots: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/database/snapshot/{filename}")
|
||||
def download_snapshot(filename: str):
|
||||
"""Download a specific snapshot file"""
|
||||
try:
|
||||
snapshot_path = backup_service.download_snapshot(filename)
|
||||
return FileResponse(
|
||||
path=str(snapshot_path),
|
||||
filename=filename,
|
||||
media_type="application/x-sqlite3"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Snapshot {filename} not found")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Download failed: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/database/snapshot/{filename}")
|
||||
def delete_database_snapshot(filename: str):
|
||||
"""Delete a specific snapshot"""
|
||||
try:
|
||||
backup_service.delete_snapshot(filename)
|
||||
return {
|
||||
"message": f"Snapshot {filename} deleted successfully",
|
||||
"filename": filename
|
||||
}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Snapshot {filename} not found")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
|
||||
|
||||
|
||||
class RestoreRequest(BaseModel):
|
||||
"""Schema for restore request"""
|
||||
filename: str
|
||||
create_backup: bool = True
|
||||
|
||||
|
||||
@router.post("/database/restore")
|
||||
def restore_database(request: RestoreRequest, db: Session = Depends(get_db)):
|
||||
"""Restore database from a snapshot"""
|
||||
try:
|
||||
# Close the database connection before restoring
|
||||
db.close()
|
||||
|
||||
result = backup_service.restore_snapshot(
|
||||
filename=request.filename,
|
||||
create_backup_before_restore=request.create_backup
|
||||
)
|
||||
|
||||
return result
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Snapshot {request.filename} not found")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Restore failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/database/upload-snapshot")
|
||||
async def upload_snapshot(file: UploadFile = File(...)):
|
||||
"""Upload a snapshot file to the backups directory"""
|
||||
if not file.filename.endswith('.db'):
|
||||
raise HTTPException(status_code=400, detail="File must be a .db file")
|
||||
|
||||
try:
|
||||
# Save uploaded file to backups directory
|
||||
backups_dir = Path("./data/backups")
|
||||
backups_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
uploaded_filename = f"snapshot_uploaded_{timestamp}.db"
|
||||
file_path = backups_dir / uploaded_filename
|
||||
|
||||
# Save file
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
# Create metadata
|
||||
metadata = {
|
||||
"filename": uploaded_filename,
|
||||
"created_at": timestamp,
|
||||
"created_at_iso": datetime.utcnow().isoformat(),
|
||||
"description": f"Uploaded: {file.filename}",
|
||||
"size_bytes": file_path.stat().st_size,
|
||||
"size_mb": round(file_path.stat().st_size / (1024 * 1024), 2),
|
||||
"type": "uploaded"
|
||||
}
|
||||
|
||||
metadata_path = backups_dir / f"{uploaded_filename}.meta.json"
|
||||
import json
|
||||
with open(metadata_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
return {
|
||||
"message": "Snapshot uploaded successfully",
|
||||
"snapshot": metadata
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
||||
44
app/seismo/routers/units.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["units"])
|
||||
|
||||
|
||||
@router.get("/unit/{unit_id}")
|
||||
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Returns detailed data for a single unit.
|
||||
"""
|
||||
snapshot = emit_status_snapshot()
|
||||
|
||||
if unit_id not in snapshot["units"]:
|
||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||
|
||||
unit_data = snapshot["units"][unit_id]
|
||||
|
||||
# Mock coordinates for now (will be replaced with real data)
|
||||
mock_coords = {
|
||||
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
|
||||
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
|
||||
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
|
||||
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
|
||||
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
|
||||
}
|
||||
|
||||
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
|
||||
|
||||
return {
|
||||
"id": unit_id,
|
||||
"status": unit_data["status"],
|
||||
"age": unit_data["age"],
|
||||
"last_seen": unit_data["last"],
|
||||
"last_file": unit_data.get("fname", ""),
|
||||
"deployed": unit_data["deployed"],
|
||||
"note": unit_data.get("note", ""),
|
||||
"coordinates": coords
|
||||
}
|
||||
286
app/seismo/routes.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import Emitter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Helper function to detect unit type from unit ID
|
||||
def detect_unit_type(unit_id: str) -> str:
|
||||
"""
|
||||
Automatically detect if a unit is Series 3 or Series 4 based on ID pattern.
|
||||
|
||||
Series 4 (Micromate) units have IDs starting with "UM" followed by digits (e.g., UM11719)
|
||||
Series 3 units typically have other patterns
|
||||
|
||||
Returns:
|
||||
"series4" if the unit ID matches Micromate pattern (UM#####)
|
||||
"series3" otherwise
|
||||
"""
|
||||
if not unit_id:
|
||||
return "unknown"
|
||||
|
||||
# Series 4 (Micromate) pattern: UM followed by digits
|
||||
if unit_id.upper().startswith("UM") and len(unit_id) > 2:
|
||||
# Check if remaining characters after "UM" are digits
|
||||
rest = unit_id[2:]
|
||||
if rest.isdigit():
|
||||
return "series4"
|
||||
|
||||
# Default to series3 for other patterns
|
||||
return "series3"
|
||||
|
||||
|
||||
# Pydantic schemas for request/response validation
|
||||
class EmitterReport(BaseModel):
|
||||
unit: str
|
||||
unit_type: str
|
||||
timestamp: str
|
||||
file: str
|
||||
status: str
|
||||
|
||||
|
||||
class EmitterResponse(BaseModel):
|
||||
id: str
|
||||
unit_type: str
|
||||
last_seen: datetime
|
||||
last_file: str
|
||||
status: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("/emitters/report", status_code=200)
|
||||
def report_emitter(report: EmitterReport, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Endpoint for emitters to report their status.
|
||||
Creates a new emitter if it doesn't exist, or updates an existing one.
|
||||
"""
|
||||
try:
|
||||
# Parse the timestamp
|
||||
timestamp = datetime.fromisoformat(report.timestamp.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid timestamp format")
|
||||
|
||||
# Check if emitter already exists
|
||||
emitter = db.query(Emitter).filter(Emitter.id == report.unit).first()
|
||||
|
||||
if emitter:
|
||||
# Update existing emitter
|
||||
emitter.unit_type = report.unit_type
|
||||
emitter.last_seen = timestamp
|
||||
emitter.last_file = report.file
|
||||
emitter.status = report.status
|
||||
else:
|
||||
# Create new emitter
|
||||
emitter = Emitter(
|
||||
id=report.unit,
|
||||
unit_type=report.unit_type,
|
||||
last_seen=timestamp,
|
||||
last_file=report.file,
|
||||
status=report.status
|
||||
)
|
||||
db.add(emitter)
|
||||
|
||||
db.commit()
|
||||
db.refresh(emitter)
|
||||
|
||||
return {
|
||||
"message": "Emitter report received",
|
||||
"unit": emitter.id,
|
||||
"status": emitter.status
|
||||
}
|
||||
|
||||
|
||||
@router.get("/fleet/status", response_model=List[EmitterResponse])
|
||||
def get_fleet_status(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Returns a list of all emitters and their current status.
|
||||
"""
|
||||
emitters = db.query(Emitter).all()
|
||||
return emitters
|
||||
|
||||
# series3v1.1 Standardized Heartbeat Schema (multi-unit)
|
||||
from fastapi import Request
|
||||
|
||||
@router.post("/api/series3/heartbeat", status_code=200)
|
||||
async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Accepts a full telemetry payload from the Series3 emitter.
|
||||
Updates or inserts each unit into the database.
|
||||
"""
|
||||
payload = await request.json()
|
||||
|
||||
source = payload.get("source_id")
|
||||
units = payload.get("units", [])
|
||||
|
||||
print("\n=== Series 3 Heartbeat ===")
|
||||
print("Source:", source)
|
||||
print("Units received:", len(units))
|
||||
print("==========================\n")
|
||||
|
||||
results = []
|
||||
|
||||
for u in units:
|
||||
uid = u.get("unit_id")
|
||||
last_event_time = u.get("last_event_time")
|
||||
event_meta = u.get("event_metadata", {})
|
||||
age_minutes = u.get("age_minutes")
|
||||
|
||||
try:
|
||||
if last_event_time:
|
||||
ts = datetime.fromisoformat(last_event_time.replace("Z", "+00:00"))
|
||||
else:
|
||||
ts = None
|
||||
except:
|
||||
ts = None
|
||||
|
||||
# Pull from DB
|
||||
emitter = db.query(Emitter).filter(Emitter.id == uid).first()
|
||||
|
||||
# File name (from event_metadata)
|
||||
last_file = event_meta.get("file_name")
|
||||
status = "Unknown"
|
||||
|
||||
# Determine status based on age
|
||||
if age_minutes is None:
|
||||
status = "Missing"
|
||||
elif age_minutes > 24 * 60:
|
||||
status = "Missing"
|
||||
elif age_minutes > 12 * 60:
|
||||
status = "Pending"
|
||||
else:
|
||||
status = "OK"
|
||||
|
||||
if emitter:
|
||||
# Update existing
|
||||
emitter.last_seen = ts
|
||||
emitter.last_file = last_file
|
||||
emitter.status = status
|
||||
# Update unit_type if it was incorrectly classified
|
||||
detected_type = detect_unit_type(uid)
|
||||
if emitter.unit_type != detected_type:
|
||||
emitter.unit_type = detected_type
|
||||
else:
|
||||
# Insert new - auto-detect unit type from ID
|
||||
detected_type = detect_unit_type(uid)
|
||||
emitter = Emitter(
|
||||
id=uid,
|
||||
unit_type=detected_type,
|
||||
last_seen=ts,
|
||||
last_file=last_file,
|
||||
status=status
|
||||
)
|
||||
db.add(emitter)
|
||||
|
||||
results.append({"unit": uid, "status": status})
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Heartbeat processed",
|
||||
"source": source,
|
||||
"units_processed": len(results),
|
||||
"results": results
|
||||
}
|
||||
|
||||
|
||||
# series4 (Micromate) Standardized Heartbeat Schema
|
||||
@router.post("/api/series4/heartbeat", status_code=200)
|
||||
async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Accepts a full telemetry payload from the Series4 (Micromate) emitter.
|
||||
Updates or inserts each unit into the database.
|
||||
|
||||
Expected payload:
|
||||
{
|
||||
"source": "series4_emitter",
|
||||
"generated_at": "2025-12-04T20:01:00",
|
||||
"units": [
|
||||
{
|
||||
"unit_id": "UM11719",
|
||||
"type": "micromate",
|
||||
"project_hint": "Clearwater - ECMS 57940",
|
||||
"last_call": "2025-12-04T19:30:42",
|
||||
"status": "OK",
|
||||
"age_days": 0.04,
|
||||
"age_hours": 0.9,
|
||||
"mlg_path": "C:\\THORDATA\\..."
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
payload = await request.json()
|
||||
|
||||
source = payload.get("source", "series4_emitter")
|
||||
units = payload.get("units", [])
|
||||
|
||||
print("\n=== Series 4 Heartbeat ===")
|
||||
print("Source:", source)
|
||||
print("Units received:", len(units))
|
||||
print("==========================\n")
|
||||
|
||||
results = []
|
||||
|
||||
for u in units:
|
||||
uid = u.get("unit_id")
|
||||
last_call_str = u.get("last_call")
|
||||
status = u.get("status", "Unknown")
|
||||
mlg_path = u.get("mlg_path")
|
||||
project_hint = u.get("project_hint")
|
||||
|
||||
# Parse last_call timestamp
|
||||
try:
|
||||
if last_call_str:
|
||||
ts = datetime.fromisoformat(last_call_str.replace("Z", "+00:00"))
|
||||
else:
|
||||
ts = None
|
||||
except:
|
||||
ts = None
|
||||
|
||||
# Pull from DB
|
||||
emitter = db.query(Emitter).filter(Emitter.id == uid).first()
|
||||
|
||||
if emitter:
|
||||
# Update existing
|
||||
emitter.last_seen = ts
|
||||
emitter.last_file = mlg_path
|
||||
emitter.status = status
|
||||
# Update unit_type if it was incorrectly classified
|
||||
detected_type = detect_unit_type(uid)
|
||||
if emitter.unit_type != detected_type:
|
||||
emitter.unit_type = detected_type
|
||||
# Optionally update notes with project hint if it exists
|
||||
if project_hint and not emitter.notes:
|
||||
emitter.notes = f"Project: {project_hint}"
|
||||
else:
|
||||
# Insert new - auto-detect unit type from ID
|
||||
detected_type = detect_unit_type(uid)
|
||||
notes = f"Project: {project_hint}" if project_hint else None
|
||||
emitter = Emitter(
|
||||
id=uid,
|
||||
unit_type=detected_type,
|
||||
last_seen=ts,
|
||||
last_file=mlg_path,
|
||||
status=status,
|
||||
notes=notes
|
||||
)
|
||||
db.add(emitter)
|
||||
|
||||
results.append({"unit": uid, "status": status})
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Heartbeat processed",
|
||||
"source": source,
|
||||
"units_processed": len(results),
|
||||
"results": results
|
||||
}
|
||||
0
app/seismo/services/__init__.py
Normal file
145
app/seismo/services/backup_scheduler.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Automatic Database Backup Scheduler
|
||||
Handles scheduled automatic backups of the database
|
||||
"""
|
||||
|
||||
import schedule
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from backend.services.database_backup import DatabaseBackupService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackupScheduler:
|
||||
"""Manages automatic database backups on a schedule"""
|
||||
|
||||
def __init__(self, db_path: str = "./data/seismo_fleet.db", backups_dir: str = "./data/backups"):
|
||||
self.backup_service = DatabaseBackupService(db_path=db_path, backups_dir=backups_dir)
|
||||
self.scheduler_thread: Optional[threading.Thread] = None
|
||||
self.is_running = False
|
||||
|
||||
# Default settings
|
||||
self.backup_interval_hours = 24 # Daily backups
|
||||
self.keep_count = 10 # Keep last 10 backups
|
||||
self.enabled = False
|
||||
|
||||
def configure(self, interval_hours: int = 24, keep_count: int = 10, enabled: bool = True):
|
||||
"""
|
||||
Configure backup scheduler settings
|
||||
|
||||
Args:
|
||||
interval_hours: Hours between automatic backups
|
||||
keep_count: Number of backups to retain
|
||||
enabled: Whether automatic backups are enabled
|
||||
"""
|
||||
self.backup_interval_hours = interval_hours
|
||||
self.keep_count = keep_count
|
||||
self.enabled = enabled
|
||||
|
||||
logger.info(f"Backup scheduler configured: interval={interval_hours}h, keep={keep_count}, enabled={enabled}")
|
||||
|
||||
def create_automatic_backup(self):
|
||||
"""Create an automatic backup and cleanup old ones"""
|
||||
if not self.enabled:
|
||||
logger.info("Automatic backups are disabled, skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
||||
description = f"Automatic backup - {timestamp}"
|
||||
|
||||
logger.info("Creating automatic backup...")
|
||||
snapshot = self.backup_service.create_snapshot(description=description)
|
||||
|
||||
logger.info(f"Automatic backup created: {snapshot['filename']} ({snapshot['size_mb']} MB)")
|
||||
|
||||
# Cleanup old backups
|
||||
cleanup_result = self.backup_service.cleanup_old_snapshots(keep_count=self.keep_count)
|
||||
if cleanup_result['deleted'] > 0:
|
||||
logger.info(f"Cleaned up {cleanup_result['deleted']} old snapshots")
|
||||
|
||||
return snapshot
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Automatic backup failed: {str(e)}")
|
||||
return None
|
||||
|
||||
def start(self):
|
||||
"""Start the backup scheduler in a background thread"""
|
||||
if self.is_running:
|
||||
logger.warning("Backup scheduler is already running")
|
||||
return
|
||||
|
||||
if not self.enabled:
|
||||
logger.info("Backup scheduler is disabled, not starting")
|
||||
return
|
||||
|
||||
logger.info(f"Starting backup scheduler (every {self.backup_interval_hours} hours)")
|
||||
|
||||
# Clear any existing scheduled jobs
|
||||
schedule.clear()
|
||||
|
||||
# Schedule the backup job
|
||||
schedule.every(self.backup_interval_hours).hours.do(self.create_automatic_backup)
|
||||
|
||||
# Also run immediately on startup
|
||||
self.create_automatic_backup()
|
||||
|
||||
# Start the scheduler thread
|
||||
self.is_running = True
|
||||
self.scheduler_thread = threading.Thread(target=self._run_scheduler, daemon=True)
|
||||
self.scheduler_thread.start()
|
||||
|
||||
logger.info("Backup scheduler started successfully")
|
||||
|
||||
def _run_scheduler(self):
|
||||
"""Internal method to run the scheduler loop"""
|
||||
while self.is_running:
|
||||
schedule.run_pending()
|
||||
time.sleep(60) # Check every minute
|
||||
|
||||
def stop(self):
|
||||
"""Stop the backup scheduler"""
|
||||
if not self.is_running:
|
||||
logger.warning("Backup scheduler is not running")
|
||||
return
|
||||
|
||||
logger.info("Stopping backup scheduler...")
|
||||
self.is_running = False
|
||||
schedule.clear()
|
||||
|
||||
if self.scheduler_thread:
|
||||
self.scheduler_thread.join(timeout=5)
|
||||
|
||||
logger.info("Backup scheduler stopped")
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get current scheduler status"""
|
||||
next_run = None
|
||||
if self.is_running and schedule.jobs:
|
||||
next_run = schedule.jobs[0].next_run.isoformat() if schedule.jobs[0].next_run else None
|
||||
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"running": self.is_running,
|
||||
"interval_hours": self.backup_interval_hours,
|
||||
"keep_count": self.keep_count,
|
||||
"next_run": next_run
|
||||
}
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
_scheduler_instance: Optional[BackupScheduler] = None
|
||||
|
||||
|
||||
def get_backup_scheduler() -> BackupScheduler:
|
||||
"""Get or create the global backup scheduler instance"""
|
||||
global _scheduler_instance
|
||||
if _scheduler_instance is None:
|
||||
_scheduler_instance = BackupScheduler()
|
||||
return _scheduler_instance
|
||||
192
app/seismo/services/database_backup.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Database Backup and Restore Service
|
||||
Handles full database snapshots, restoration, and remote synchronization
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
import json
|
||||
|
||||
|
||||
class DatabaseBackupService:
|
||||
"""Manages database backup operations"""
|
||||
|
||||
def __init__(self, db_path: str = "./data/seismo_fleet.db", backups_dir: str = "./data/backups"):
|
||||
self.db_path = Path(db_path)
|
||||
self.backups_dir = Path(backups_dir)
|
||||
self.backups_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def create_snapshot(self, description: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
Create a full database snapshot using SQLite backup API
|
||||
Returns snapshot metadata
|
||||
"""
|
||||
if not self.db_path.exists():
|
||||
raise FileNotFoundError(f"Database not found at {self.db_path}")
|
||||
|
||||
# Generate snapshot filename with timestamp
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
snapshot_name = f"snapshot_{timestamp}.db"
|
||||
snapshot_path = self.backups_dir / snapshot_name
|
||||
|
||||
# Get database size before backup
|
||||
db_size = self.db_path.stat().st_size
|
||||
|
||||
try:
|
||||
# Use SQLite backup API for safe backup (handles concurrent access)
|
||||
source_conn = sqlite3.connect(str(self.db_path))
|
||||
dest_conn = sqlite3.connect(str(snapshot_path))
|
||||
|
||||
# Perform the backup
|
||||
with dest_conn:
|
||||
source_conn.backup(dest_conn)
|
||||
|
||||
source_conn.close()
|
||||
dest_conn.close()
|
||||
|
||||
# Create metadata
|
||||
metadata = {
|
||||
"filename": snapshot_name,
|
||||
"created_at": timestamp,
|
||||
"created_at_iso": datetime.utcnow().isoformat(),
|
||||
"description": description or "Manual snapshot",
|
||||
"size_bytes": snapshot_path.stat().st_size,
|
||||
"size_mb": round(snapshot_path.stat().st_size / (1024 * 1024), 2),
|
||||
"original_db_size_bytes": db_size,
|
||||
"type": "manual"
|
||||
}
|
||||
|
||||
# Save metadata as JSON sidecar file
|
||||
metadata_path = self.backups_dir / f"{snapshot_name}.meta.json"
|
||||
with open(metadata_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
return metadata
|
||||
|
||||
except Exception as e:
|
||||
# Clean up partial snapshot if it exists
|
||||
if snapshot_path.exists():
|
||||
snapshot_path.unlink()
|
||||
raise Exception(f"Snapshot creation failed: {str(e)}")
|
||||
|
||||
def list_snapshots(self) -> List[Dict]:
|
||||
"""
|
||||
List all available snapshots with metadata
|
||||
Returns list sorted by creation date (newest first)
|
||||
"""
|
||||
snapshots = []
|
||||
|
||||
for db_file in sorted(self.backups_dir.glob("snapshot_*.db"), reverse=True):
|
||||
metadata_file = self.backups_dir / f"{db_file.name}.meta.json"
|
||||
|
||||
if metadata_file.exists():
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = json.load(f)
|
||||
else:
|
||||
# Fallback for legacy snapshots without metadata
|
||||
stat_info = db_file.stat()
|
||||
metadata = {
|
||||
"filename": db_file.name,
|
||||
"created_at": datetime.fromtimestamp(stat_info.st_mtime).strftime("%Y%m%d_%H%M%S"),
|
||||
"created_at_iso": datetime.fromtimestamp(stat_info.st_mtime).isoformat(),
|
||||
"description": "Legacy snapshot",
|
||||
"size_bytes": stat_info.st_size,
|
||||
"size_mb": round(stat_info.st_size / (1024 * 1024), 2),
|
||||
"type": "manual"
|
||||
}
|
||||
|
||||
snapshots.append(metadata)
|
||||
|
||||
return snapshots
|
||||
|
||||
def delete_snapshot(self, filename: str) -> bool:
|
||||
"""Delete a snapshot and its metadata"""
|
||||
snapshot_path = self.backups_dir / filename
|
||||
metadata_path = self.backups_dir / f"{filename}.meta.json"
|
||||
|
||||
if not snapshot_path.exists():
|
||||
raise FileNotFoundError(f"Snapshot {filename} not found")
|
||||
|
||||
snapshot_path.unlink()
|
||||
if metadata_path.exists():
|
||||
metadata_path.unlink()
|
||||
|
||||
return True
|
||||
|
||||
def restore_snapshot(self, filename: str, create_backup_before_restore: bool = True) -> Dict:
|
||||
"""
|
||||
Restore database from a snapshot
|
||||
Creates a safety backup before restoring if requested
|
||||
"""
|
||||
snapshot_path = self.backups_dir / filename
|
||||
|
||||
if not snapshot_path.exists():
|
||||
raise FileNotFoundError(f"Snapshot {filename} not found")
|
||||
|
||||
if not self.db_path.exists():
|
||||
raise FileNotFoundError(f"Database not found at {self.db_path}")
|
||||
|
||||
backup_info = None
|
||||
|
||||
# Create safety backup before restore
|
||||
if create_backup_before_restore:
|
||||
backup_info = self.create_snapshot(description="Auto-backup before restore")
|
||||
|
||||
try:
|
||||
# Replace database file
|
||||
shutil.copy2(str(snapshot_path), str(self.db_path))
|
||||
|
||||
return {
|
||||
"message": "Database restored successfully",
|
||||
"restored_from": filename,
|
||||
"restored_at": datetime.utcnow().isoformat(),
|
||||
"backup_created": backup_info["filename"] if backup_info else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Restore failed: {str(e)}")
|
||||
|
||||
def get_database_stats(self) -> Dict:
|
||||
"""Get statistics about the current database"""
|
||||
if not self.db_path.exists():
|
||||
return {"error": "Database not found"}
|
||||
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get table counts
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
tables = cursor.fetchall()
|
||||
|
||||
table_stats = {}
|
||||
total_rows = 0
|
||||
|
||||
for (table_name,) in tables:
|
||||
cursor.execute(f"SELECT COUNT(*) FROM {table_name}")
|
||||
count = cursor.fetchone()[0]
|
||||
table_stats[table_name] = count
|
||||
total_rows += count
|
||||
|
||||
conn.close()
|
||||
|
||||
db_size = self.db_path.stat().st_size
|
||||
|
||||
return {
|
||||
"database_path": str(self.db_path),
|
||||
"size_bytes": db_size,
|
||||
"size_mb": round(db_size / (1024 * 1024), 2),
|
||||
"total_rows": total_rows,
|
||||
"tables": table_stats,
|
||||
"last_modified": datetime.fromtimestamp(self.db_path.stat().st_mtime).isoformat()
|
||||
}
|
||||
|
||||
def download_snapshot(self, filename: str) -> Path:
|
||||
"""Get the file path for downloading a snapshot"""
|
||||
snapshot_path = self.backups_dir / filename
|
||||
if not snapshot_path.exists():
|
||||
raise FileNotFoundError(f"Snapshot {filename} not found")
|
||||
return snapshot_path
|
||||
191
app/seismo/services/snapshot.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db_session
|
||||
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
||||
|
||||
|
||||
def ensure_utc(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def format_age(last_seen):
|
||||
if not last_seen:
|
||||
return "N/A"
|
||||
last_seen = ensure_utc(last_seen)
|
||||
now = datetime.now(timezone.utc)
|
||||
diff = now - last_seen
|
||||
hours = diff.total_seconds() // 3600
|
||||
mins = (diff.total_seconds() % 3600) // 60
|
||||
return f"{int(hours)}h {int(mins)}m"
|
||||
|
||||
|
||||
def calculate_status(last_seen, status_ok_threshold=12, status_pending_threshold=24):
|
||||
"""
|
||||
Calculate status based on how long ago the unit was last seen.
|
||||
|
||||
Args:
|
||||
last_seen: datetime of last seen (UTC)
|
||||
status_ok_threshold: hours before status becomes Pending (default 12)
|
||||
status_pending_threshold: hours before status becomes Missing (default 24)
|
||||
|
||||
Returns:
|
||||
"OK", "Pending", or "Missing"
|
||||
"""
|
||||
if not last_seen:
|
||||
return "Missing"
|
||||
|
||||
last_seen = ensure_utc(last_seen)
|
||||
now = datetime.now(timezone.utc)
|
||||
hours_ago = (now - last_seen).total_seconds() / 3600
|
||||
|
||||
if hours_ago > status_pending_threshold:
|
||||
return "Missing"
|
||||
elif hours_ago > status_ok_threshold:
|
||||
return "Pending"
|
||||
else:
|
||||
return "OK"
|
||||
|
||||
|
||||
def emit_status_snapshot():
|
||||
"""
|
||||
Merge roster (what we *intend*) with emitter data (what is *actually happening*).
|
||||
Status is recalculated based on current time to ensure accuracy.
|
||||
"""
|
||||
|
||||
db = get_db_session()
|
||||
try:
|
||||
# Get user preferences for status thresholds
|
||||
from backend.models import UserPreferences
|
||||
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
||||
status_ok_threshold = prefs.status_ok_threshold_hours if prefs else 12
|
||||
status_pending_threshold = prefs.status_pending_threshold_hours if prefs else 24
|
||||
|
||||
roster = {r.id: r for r in db.query(RosterUnit).all()}
|
||||
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
||||
|
||||
units = {}
|
||||
|
||||
# --- Merge roster entries first ---
|
||||
for unit_id, r in roster.items():
|
||||
e = emitters.get(unit_id)
|
||||
if r.retired:
|
||||
# Retired units get separated later
|
||||
status = "Retired"
|
||||
age = "N/A"
|
||||
last_seen = None
|
||||
fname = ""
|
||||
else:
|
||||
if e:
|
||||
last_seen = ensure_utc(e.last_seen)
|
||||
# RECALCULATE status based on current time, not stored value
|
||||
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||
age = format_age(last_seen)
|
||||
fname = e.last_file
|
||||
else:
|
||||
# Rostered but no emitter data
|
||||
status = "Missing"
|
||||
last_seen = None
|
||||
age = "N/A"
|
||||
fname = ""
|
||||
|
||||
units[unit_id] = {
|
||||
"id": unit_id,
|
||||
"status": status,
|
||||
"age": age,
|
||||
"last": last_seen.isoformat() if last_seen else None,
|
||||
"fname": fname,
|
||||
"deployed": r.deployed,
|
||||
"note": r.note or "",
|
||||
"retired": r.retired,
|
||||
# Device type and type-specific fields
|
||||
"device_type": r.device_type or "seismograph",
|
||||
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||
"next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None,
|
||||
"deployed_with_modem_id": r.deployed_with_modem_id,
|
||||
"ip_address": r.ip_address,
|
||||
"phone_number": r.phone_number,
|
||||
"hardware_model": r.hardware_model,
|
||||
# Location for mapping
|
||||
"location": r.location or "",
|
||||
"address": r.address or "",
|
||||
"coordinates": r.coordinates or "",
|
||||
}
|
||||
|
||||
# --- Add unexpected emitter-only units ---
|
||||
for unit_id, e in emitters.items():
|
||||
if unit_id not in roster:
|
||||
last_seen = ensure_utc(e.last_seen)
|
||||
# RECALCULATE status for unknown units too
|
||||
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||
units[unit_id] = {
|
||||
"id": unit_id,
|
||||
"status": status,
|
||||
"age": format_age(last_seen),
|
||||
"last": last_seen.isoformat(),
|
||||
"fname": e.last_file,
|
||||
"deployed": False, # default
|
||||
"note": "",
|
||||
"retired": False,
|
||||
# Device type and type-specific fields (defaults for unknown units)
|
||||
"device_type": "seismograph", # default
|
||||
"last_calibrated": None,
|
||||
"next_calibration_due": None,
|
||||
"deployed_with_modem_id": None,
|
||||
"ip_address": None,
|
||||
"phone_number": None,
|
||||
"hardware_model": None,
|
||||
# Location fields
|
||||
"location": "",
|
||||
"address": "",
|
||||
"coordinates": "",
|
||||
}
|
||||
|
||||
# Separate buckets for UI
|
||||
active_units = {
|
||||
uid: u for uid, u in units.items()
|
||||
if not u["retired"] and u["deployed"] and uid not in ignored
|
||||
}
|
||||
|
||||
benched_units = {
|
||||
uid: u for uid, u in units.items()
|
||||
if not u["retired"] and not u["deployed"] and uid not in ignored
|
||||
}
|
||||
|
||||
retired_units = {
|
||||
uid: u for uid, u in units.items()
|
||||
if u["retired"]
|
||||
}
|
||||
|
||||
# Unknown units - emitters that aren't in the roster and aren't ignored
|
||||
unknown_units = {
|
||||
uid: u for uid, u in units.items()
|
||||
if uid not in roster and uid not in ignored
|
||||
}
|
||||
|
||||
return {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"units": units,
|
||||
"active": active_units,
|
||||
"benched": benched_units,
|
||||
"retired": retired_units,
|
||||
"unknown": unknown_units,
|
||||
"summary": {
|
||||
"total": len(active_units) + len(benched_units),
|
||||
"active": len(active_units),
|
||||
"benched": len(benched_units),
|
||||
"retired": len(retired_units),
|
||||
"unknown": len(unknown_units),
|
||||
# Status counts only for deployed units (active_units)
|
||||
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
|
||||
"pending": sum(1 for u in active_units.values() if u["status"] == "Pending"),
|
||||
"missing": sum(1 for u in active_units.values() if u["status"] == "Missing"),
|
||||
}
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
0
app/slm/__init__.py
Normal file
36
app/slm/database.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Sound Level Meter feature module database connection
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
|
||||
# Ensure data directory exists
|
||||
os.makedirs("data", exist_ok=True)
|
||||
|
||||
# For now, we'll use the shared database (seismo_fleet.db) until we migrate
|
||||
# TODO: Migrate to slm.db
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency for database sessions"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""Get a database session directly (not as a dependency)"""
|
||||
return SessionLocal()
|
||||
10
app/slm/models.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Sound Level Meter feature module models
|
||||
"""
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, Float
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# TODO: When we split databases, SLM-specific models will go here
|
||||
# For now, SLM data is in the shared seismo_fleet.db database
|
||||
0
app/slm/routers/__init__.py
Normal file
328
app/slm/routers/dashboard.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
SLM Dashboard Router
|
||||
|
||||
Provides API endpoints for the Sound Level Meters dashboard page.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
import httpx
|
||||
import logging
|
||||
import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
from backend.routers.roster_edit import sync_slm_to_slmm_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# SLMM backend URL - configurable via environment variable
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
|
||||
@router.get("/stats", response_class=HTMLResponse)
|
||||
async def get_slm_stats(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get summary statistics for SLM dashboard.
|
||||
Returns HTML partial with stat cards.
|
||||
"""
|
||||
# Query all SLMs
|
||||
all_slms = db.query(RosterUnit).filter_by(device_type="sound_level_meter").all()
|
||||
|
||||
# Count deployed vs benched
|
||||
deployed_count = sum(1 for slm in all_slms if slm.deployed and not slm.retired)
|
||||
benched_count = sum(1 for slm in all_slms if not slm.deployed and not slm.retired)
|
||||
retired_count = sum(1 for slm in all_slms if slm.retired)
|
||||
|
||||
# Count recently active (checked in last hour)
|
||||
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
||||
active_count = sum(1 for slm in all_slms
|
||||
if slm.slm_last_check and slm.slm_last_check > one_hour_ago)
|
||||
|
||||
return templates.TemplateResponse("partials/slm_stats.html", {
|
||||
"request": request,
|
||||
"total_count": len(all_slms),
|
||||
"deployed_count": deployed_count,
|
||||
"benched_count": benched_count,
|
||||
"active_count": active_count,
|
||||
"retired_count": retired_count
|
||||
})
|
||||
|
||||
|
||||
@router.get("/units", response_class=HTMLResponse)
|
||||
async def get_slm_units(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
search: str = Query(None)
|
||||
):
|
||||
"""
|
||||
Get list of SLM units for the sidebar.
|
||||
Returns HTML partial with unit cards.
|
||||
"""
|
||||
query = db.query(RosterUnit).filter_by(device_type="sound_level_meter")
|
||||
|
||||
# Filter by search term if provided
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(RosterUnit.id.like(search_term)) |
|
||||
(RosterUnit.slm_model.like(search_term)) |
|
||||
(RosterUnit.address.like(search_term))
|
||||
)
|
||||
|
||||
# Only show deployed units by default
|
||||
units = query.filter_by(deployed=True, retired=False).order_by(RosterUnit.id).all()
|
||||
|
||||
return templates.TemplateResponse("partials/slm_unit_list.html", {
|
||||
"request": request,
|
||||
"units": units
|
||||
})
|
||||
|
||||
|
||||
@router.get("/live-view/{unit_id}", response_class=HTMLResponse)
|
||||
async def get_live_view(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get live view panel for a specific SLM unit.
|
||||
Returns HTML partial with live metrics and chart.
|
||||
"""
|
||||
# Get unit from database
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first()
|
||||
|
||||
if not unit:
|
||||
return templates.TemplateResponse("partials/slm_live_view_error.html", {
|
||||
"request": request,
|
||||
"error": f"Unit {unit_id} not found"
|
||||
})
|
||||
|
||||
# Get modem information if assigned
|
||||
modem = None
|
||||
modem_ip = None
|
||||
if unit.deployed_with_modem_id:
|
||||
modem = db.query(RosterUnit).filter_by(id=unit.deployed_with_modem_id, device_type="modem").first()
|
||||
if modem:
|
||||
modem_ip = modem.ip_address
|
||||
else:
|
||||
logger.warning(f"SLM {unit_id} is assigned to modem {unit.deployed_with_modem_id} but modem not found")
|
||||
|
||||
# Fallback to direct slm_host if no modem assigned (backward compatibility)
|
||||
if not modem_ip and unit.slm_host:
|
||||
modem_ip = unit.slm_host
|
||||
logger.info(f"Using legacy slm_host for {unit_id}: {modem_ip}")
|
||||
|
||||
# Try to get current status from SLMM
|
||||
current_status = None
|
||||
measurement_state = None
|
||||
is_measuring = False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
# Get measurement state
|
||||
state_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||
)
|
||||
if state_response.status_code == 200:
|
||||
state_data = state_response.json()
|
||||
measurement_state = state_data.get("measurement_state", "Unknown")
|
||||
is_measuring = state_data.get("is_measuring", False)
|
||||
|
||||
# Get live status
|
||||
status_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
||||
)
|
||||
if status_response.status_code == 200:
|
||||
status_data = status_response.json()
|
||||
current_status = status_data.get("data", {})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get status for {unit_id}: {e}")
|
||||
|
||||
return templates.TemplateResponse("partials/slm_live_view.html", {
|
||||
"request": request,
|
||||
"unit": unit,
|
||||
"modem": modem,
|
||||
"modem_ip": modem_ip,
|
||||
"current_status": current_status,
|
||||
"measurement_state": measurement_state,
|
||||
"is_measuring": is_measuring
|
||||
})
|
||||
|
||||
|
||||
@router.post("/control/{unit_id}/{action}")
|
||||
async def control_slm(unit_id: str, action: str):
|
||||
"""
|
||||
Send control commands to SLM (start, stop, pause, resume, reset).
|
||||
Proxies to SLMM backend.
|
||||
"""
|
||||
valid_actions = ["start", "stop", "pause", "resume", "reset"]
|
||||
|
||||
if action not in valid_actions:
|
||||
return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}"
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": f"SLMM returned status {response.status_code}"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to control {unit_id}: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": str(e)
|
||||
}
|
||||
|
||||
@router.get("/config/{unit_id}", response_class=HTMLResponse)
|
||||
async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get configuration form for a specific SLM unit.
|
||||
Returns HTML partial with configuration form.
|
||||
"""
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first()
|
||||
|
||||
if not unit:
|
||||
return HTMLResponse(
|
||||
content=f'<div class="text-red-500">Unit {unit_id} not found</div>',
|
||||
status_code=404
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("partials/slm_config_form.html", {
|
||||
"request": request,
|
||||
"unit": unit
|
||||
})
|
||||
|
||||
|
||||
@router.post("/config/{unit_id}")
|
||||
async def save_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Save SLM configuration.
|
||||
Updates unit parameters in the database.
|
||||
"""
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first()
|
||||
|
||||
if not unit:
|
||||
return {"status": "error", "detail": f"Unit {unit_id} not found"}
|
||||
|
||||
try:
|
||||
# Get form data
|
||||
form_data = await request.form()
|
||||
|
||||
# Update SLM-specific fields
|
||||
unit.slm_model = form_data.get("slm_model") or None
|
||||
unit.slm_serial_number = form_data.get("slm_serial_number") or None
|
||||
unit.slm_frequency_weighting = form_data.get("slm_frequency_weighting") or None
|
||||
unit.slm_time_weighting = form_data.get("slm_time_weighting") or None
|
||||
unit.slm_measurement_range = form_data.get("slm_measurement_range") or None
|
||||
|
||||
# Update network configuration
|
||||
modem_id = form_data.get("deployed_with_modem_id")
|
||||
unit.deployed_with_modem_id = modem_id if modem_id else None
|
||||
|
||||
# Always update TCP and FTP ports (used regardless of modem assignment)
|
||||
unit.slm_tcp_port = int(form_data.get("slm_tcp_port")) if form_data.get("slm_tcp_port") else None
|
||||
unit.slm_ftp_port = int(form_data.get("slm_ftp_port")) if form_data.get("slm_ftp_port") else None
|
||||
|
||||
# Only update direct IP if no modem is assigned
|
||||
if not modem_id:
|
||||
unit.slm_host = form_data.get("slm_host") or None
|
||||
else:
|
||||
# Clear legacy direct IP field when modem is assigned
|
||||
unit.slm_host = None
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Updated configuration for SLM {unit_id}")
|
||||
|
||||
# Sync updated configuration to SLMM cache
|
||||
logger.info(f"Syncing SLM {unit_id} config changes to SLMM cache...")
|
||||
result = await sync_slm_to_slmm_cache(
|
||||
unit_id=unit_id,
|
||||
host=unit.slm_host, # Use the updated host from Terra-View
|
||||
tcp_port=unit.slm_tcp_port,
|
||||
ftp_port=unit.slm_ftp_port,
|
||||
deployed_with_modem_id=unit.deployed_with_modem_id, # Resolve modem IP if assigned
|
||||
db=db
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(f"SLMM cache sync warning for {unit_id}: {result['message']}")
|
||||
# Config still saved in Terra-View (source of truth)
|
||||
|
||||
return {"status": "success", "unit_id": unit_id}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to save config for {unit_id}: {e}")
|
||||
return {"status": "error", "detail": str(e)}
|
||||
|
||||
|
||||
@router.get("/test-modem/{modem_id}")
|
||||
async def test_modem_connection(modem_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Test modem connectivity with a simple ping/health check.
|
||||
Returns response time and connection status.
|
||||
"""
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
# Get modem from database
|
||||
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||
|
||||
if not modem:
|
||||
return {"status": "error", "detail": f"Modem {modem_id} not found"}
|
||||
|
||||
if not modem.ip_address:
|
||||
return {"status": "error", "detail": f"Modem {modem_id} has no IP address configured"}
|
||||
|
||||
try:
|
||||
# Ping the modem (1 packet, 2 second timeout)
|
||||
start_time = time.time()
|
||||
result = subprocess.run(
|
||||
["ping", "-c", "1", "-W", "2", modem.ip_address],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3
|
||||
)
|
||||
response_time = int((time.time() - start_time) * 1000) # Convert to milliseconds
|
||||
|
||||
if result.returncode == 0:
|
||||
return {
|
||||
"status": "success",
|
||||
"modem_id": modem_id,
|
||||
"ip_address": modem.ip_address,
|
||||
"response_time": response_time,
|
||||
"message": "Modem is responding to ping"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"modem_id": modem_id,
|
||||
"ip_address": modem.ip_address,
|
||||
"detail": "Modem not responding to ping"
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"status": "error",
|
||||
"modem_id": modem_id,
|
||||
"ip_address": modem.ip_address,
|
||||
"detail": "Ping timeout (> 2 seconds)"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ping modem {modem_id}: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"modem_id": modem_id,
|
||||
"detail": str(e)
|
||||
}
|
||||
301
app/slm/routers/nl43_proxy.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
SLMM (Sound Level Meter Manager) Proxy Router
|
||||
|
||||
Proxies requests from SFM to the standalone SLMM backend service.
|
||||
SLMM runs on port 8100 and handles NL43/NL53 sound level meter communication.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import StreamingResponse
|
||||
import httpx
|
||||
import websockets
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/slmm", tags=["slmm"])
|
||||
|
||||
# SLMM backend URL - configurable via environment variable
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
# WebSocket URL derived from HTTP URL
|
||||
SLMM_WS_BASE_URL = SLMM_BASE_URL.replace("http://", "ws://").replace("https://", "wss://")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def check_slmm_health():
|
||||
"""
|
||||
Check if the SLMM backend service is reachable and healthy.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{SLMM_BASE_URL}/health")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
"status": "ok",
|
||||
"slmm_status": "connected",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"slmm_version": data.get("version", "unknown"),
|
||||
"slmm_response": data
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "degraded",
|
||||
"slmm_status": "error",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"detail": f"SLMM returned status {response.status_code}"
|
||||
}
|
||||
|
||||
except httpx.ConnectError:
|
||||
return {
|
||||
"status": "error",
|
||||
"slmm_status": "unreachable",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"detail": "Cannot connect to SLMM backend. Is it running?"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"slmm_status": "error",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"detail": str(e)
|
||||
}
|
||||
|
||||
|
||||
# WebSocket routes MUST come before the catch-all route
|
||||
@router.websocket("/{unit_id}/stream")
|
||||
async def proxy_websocket_stream(websocket: WebSocket, unit_id: str):
|
||||
"""
|
||||
Proxy WebSocket connections to SLMM's /stream endpoint.
|
||||
|
||||
This allows real-time streaming of measurement data from NL43 devices
|
||||
through the SFM unified interface.
|
||||
"""
|
||||
await websocket.accept()
|
||||
logger.info(f"WebSocket connection accepted for SLMM unit {unit_id}")
|
||||
|
||||
# Build target WebSocket URL
|
||||
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/stream"
|
||||
logger.info(f"Connecting to SLMM WebSocket: {target_ws_url}")
|
||||
|
||||
backend_ws = None
|
||||
|
||||
try:
|
||||
# Connect to SLMM backend WebSocket
|
||||
backend_ws = await websockets.connect(target_ws_url)
|
||||
logger.info(f"Connected to SLMM backend WebSocket for {unit_id}")
|
||||
|
||||
# Create tasks for bidirectional communication
|
||||
async def forward_to_backend():
|
||||
"""Forward messages from client to SLMM backend"""
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
await backend_ws.send(data)
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"Client WebSocket disconnected for {unit_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error forwarding to backend: {e}")
|
||||
|
||||
async def forward_to_client():
|
||||
"""Forward messages from SLMM backend to client"""
|
||||
try:
|
||||
async for message in backend_ws:
|
||||
await websocket.send_text(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info(f"Backend WebSocket closed for {unit_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error forwarding to client: {e}")
|
||||
|
||||
# Run both forwarding tasks concurrently
|
||||
await asyncio.gather(
|
||||
forward_to_backend(),
|
||||
forward_to_client(),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
except websockets.exceptions.WebSocketException as e:
|
||||
logger.error(f"WebSocket error connecting to SLMM backend: {e}")
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"error": "Failed to connect to SLMM backend",
|
||||
"detail": str(e)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in WebSocket proxy for {unit_id}: {e}")
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"error": "Internal server error",
|
||||
"detail": str(e)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# Clean up connections
|
||||
if backend_ws:
|
||||
try:
|
||||
await backend_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"WebSocket proxy closed for {unit_id}")
|
||||
|
||||
|
||||
@router.websocket("/{unit_id}/live")
|
||||
async def proxy_websocket_live(websocket: WebSocket, unit_id: str):
|
||||
"""
|
||||
Proxy WebSocket connections to SLMM's /live endpoint.
|
||||
|
||||
Alternative WebSocket endpoint that may be used by some frontend components.
|
||||
"""
|
||||
await websocket.accept()
|
||||
logger.info(f"WebSocket connection accepted for SLMM unit {unit_id} (live endpoint)")
|
||||
|
||||
# Build target WebSocket URL - try /stream endpoint as SLMM uses that for WebSocket
|
||||
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/stream"
|
||||
logger.info(f"Connecting to SLMM WebSocket: {target_ws_url}")
|
||||
|
||||
backend_ws = None
|
||||
|
||||
try:
|
||||
# Connect to SLMM backend WebSocket
|
||||
backend_ws = await websockets.connect(target_ws_url)
|
||||
logger.info(f"Connected to SLMM backend WebSocket for {unit_id} (live endpoint)")
|
||||
|
||||
# Create tasks for bidirectional communication
|
||||
async def forward_to_backend():
|
||||
"""Forward messages from client to SLMM backend"""
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
await backend_ws.send(data)
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"Client WebSocket disconnected for {unit_id} (live)")
|
||||
except Exception as e:
|
||||
logger.error(f"Error forwarding to backend (live): {e}")
|
||||
|
||||
async def forward_to_client():
|
||||
"""Forward messages from SLMM backend to client"""
|
||||
try:
|
||||
async for message in backend_ws:
|
||||
await websocket.send_text(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info(f"Backend WebSocket closed for {unit_id} (live)")
|
||||
except Exception as e:
|
||||
logger.error(f"Error forwarding to client (live): {e}")
|
||||
|
||||
# Run both forwarding tasks concurrently
|
||||
await asyncio.gather(
|
||||
forward_to_backend(),
|
||||
forward_to_client(),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
except websockets.exceptions.WebSocketException as e:
|
||||
logger.error(f"WebSocket error connecting to SLMM backend (live): {e}")
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"error": "Failed to connect to SLMM backend",
|
||||
"detail": str(e)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in WebSocket proxy for {unit_id} (live): {e}")
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"error": "Internal server error",
|
||||
"detail": str(e)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# Clean up connections
|
||||
if backend_ws:
|
||||
try:
|
||||
await backend_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"WebSocket proxy closed for {unit_id} (live)")
|
||||
|
||||
|
||||
# HTTP catch-all route MUST come after specific routes (including WebSocket routes)
|
||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
async def proxy_to_slmm(path: str, request: Request):
|
||||
"""
|
||||
Proxy all requests to the SLMM backend service.
|
||||
|
||||
This allows SFM to act as a unified frontend for all device types,
|
||||
while SLMM remains a standalone backend service.
|
||||
"""
|
||||
# Build target URL
|
||||
target_url = f"{SLMM_BASE_URL}/api/nl43/{path}"
|
||||
|
||||
# Get query parameters
|
||||
query_params = dict(request.query_params)
|
||||
|
||||
# Get request body if present
|
||||
body = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
try:
|
||||
body = await request.body()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read request body: {e}")
|
||||
body = None
|
||||
|
||||
# Get headers (exclude host and other proxy-specific headers)
|
||||
headers = dict(request.headers)
|
||||
headers_to_exclude = ["host", "content-length", "transfer-encoding", "connection"]
|
||||
proxy_headers = {k: v for k, v in headers.items() if k.lower() not in headers_to_exclude}
|
||||
|
||||
logger.info(f"Proxying {request.method} request to SLMM: {target_url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Forward the request to SLMM
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
params=query_params,
|
||||
headers=proxy_headers,
|
||||
content=body
|
||||
)
|
||||
|
||||
# Return the response from SLMM
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.headers.get("content-type")
|
||||
)
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.error(f"Failed to connect to SLMM backend at {SLMM_BASE_URL}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"SLMM backend service unavailable. Is SLMM running on {SLMM_BASE_URL}?"
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"Timeout connecting to SLMM backend at {SLMM_BASE_URL}")
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="SLMM backend timeout"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error proxying to SLMM: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to proxy request to SLMM: {str(e)}"
|
||||
)
|
||||
123
app/slm/routers/ui.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Sound Level Meter UI Router
|
||||
|
||||
Provides endpoints for SLM dashboard cards, detail pages, and real-time data.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
import logging
|
||||
import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/slm", tags=["slm-ui"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://172.19.0.1:8100")
|
||||
|
||||
|
||||
@router.get("/{unit_id}", response_class=HTMLResponse)
|
||||
async def slm_detail_page(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Sound level meter detail page with controls."""
|
||||
|
||||
# Get roster unit
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit or unit.device_type != "sound_level_meter":
|
||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||
|
||||
return templates.TemplateResponse("slm_detail.html", {
|
||||
"request": request,
|
||||
"unit": unit,
|
||||
"unit_id": unit_id
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/{unit_id}/summary")
|
||||
async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Get SLM summary data for dashboard card."""
|
||||
|
||||
# Get roster unit
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit or unit.device_type != "sound_level_meter":
|
||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||
|
||||
# Try to get live status from SLMM
|
||||
status_data = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
||||
if response.status_code == 200:
|
||||
status_data = response.json().get("data")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
||||
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"device_type": "sound_level_meter",
|
||||
"deployed": unit.deployed,
|
||||
"model": unit.slm_model or "NL-43",
|
||||
"location": unit.address or unit.location,
|
||||
"coordinates": unit.coordinates,
|
||||
"note": unit.note,
|
||||
"status": status_data,
|
||||
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/partials/{unit_id}/card", response_class=HTMLResponse)
|
||||
async def slm_dashboard_card(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Render SLM dashboard card partial."""
|
||||
|
||||
summary = await get_slm_summary(unit_id, db)
|
||||
|
||||
return templates.TemplateResponse("partials/slm_card.html", {
|
||||
"request": request,
|
||||
"slm": summary
|
||||
})
|
||||
|
||||
|
||||
@router.get("/partials/{unit_id}/controls", response_class=HTMLResponse)
|
||||
async def slm_controls_partial(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Render SLM control panel partial."""
|
||||
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit or unit.device_type != "sound_level_meter":
|
||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||
|
||||
# Get current status from SLMM
|
||||
measurement_state = None
|
||||
battery_level = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
# Get measurement state
|
||||
state_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||
)
|
||||
if state_response.status_code == 200:
|
||||
measurement_state = state_response.json().get("measurement_state")
|
||||
|
||||
# Get battery level
|
||||
battery_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/battery"
|
||||
)
|
||||
if battery_response.status_code == 200:
|
||||
battery_level = battery_response.json().get("battery_level")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get SLM control data for {unit_id}: {e}")
|
||||
|
||||
return templates.TemplateResponse("partials/slm_controls.html", {
|
||||
"request": request,
|
||||
"unit_id": unit_id,
|
||||
"unit": unit,
|
||||
"measurement_state": measurement_state,
|
||||
"battery_level": battery_level,
|
||||
"is_measuring": measurement_state == "Start"
|
||||
})
|
||||
0
app/slm/services/__init__.py
Normal file
0
app/ui/__init__.py
Normal file
92
app/ui/routes.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
UI Layer Routes - HTML page routes only (no business logic)
|
||||
"""
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import os
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Setup Jinja2 templates
|
||||
templates = Jinja2Templates(directory="app/ui/templates")
|
||||
|
||||
# Read environment (development or production)
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
VERSION = "1.0.0" # Terra-View version
|
||||
|
||||
# Override TemplateResponse to include environment and version in context
|
||||
original_template_response = templates.TemplateResponse
|
||||
def custom_template_response(name, context=None, *args, **kwargs):
|
||||
if context is None:
|
||||
context = {}
|
||||
context["environment"] = ENVIRONMENT
|
||||
context["version"] = VERSION
|
||||
return original_template_response(name, context, *args, **kwargs)
|
||||
templates.TemplateResponse = custom_template_response
|
||||
|
||||
|
||||
# ===== HTML PAGE ROUTES =====
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
"""Dashboard home page"""
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/roster", response_class=HTMLResponse)
|
||||
async def roster_page(request: Request):
|
||||
"""Fleet roster page"""
|
||||
return templates.TemplateResponse("roster.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/unit/{unit_id}", response_class=HTMLResponse)
|
||||
async def unit_detail_page(request: Request, unit_id: str):
|
||||
"""Unit detail page"""
|
||||
return templates.TemplateResponse("unit_detail.html", {
|
||||
"request": request,
|
||||
"unit_id": unit_id
|
||||
})
|
||||
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
"""Settings page for roster management"""
|
||||
return templates.TemplateResponse("settings.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/sound-level-meters", response_class=HTMLResponse)
|
||||
async def sound_level_meters_page(request: Request):
|
||||
"""Sound Level Meters management dashboard"""
|
||||
return templates.TemplateResponse("sound_level_meters.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/seismographs", response_class=HTMLResponse)
|
||||
async def seismographs_page(request: Request):
|
||||
"""Seismographs management dashboard"""
|
||||
return templates.TemplateResponse("seismographs.html", {"request": request})
|
||||
|
||||
|
||||
# ===== PWA ROUTES =====
|
||||
|
||||
@router.get("/sw.js")
|
||||
async def service_worker():
|
||||
"""Serve service worker with proper headers for PWA"""
|
||||
return FileResponse(
|
||||
"app/ui/static/sw.js",
|
||||
media_type="application/javascript",
|
||||
headers={
|
||||
"Service-Worker-Allowed": "/",
|
||||
"Cache-Control": "no-cache"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/offline-db.js")
|
||||
async def offline_db_script():
|
||||
"""Serve offline database script"""
|
||||
return FileResponse(
|
||||
"app/ui/static/offline-db.js",
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": "no-cache"}
|
||||
)
|
||||
78
app/ui/static/icons/ICON_GENERATION_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# PWA Icon Generation Instructions
|
||||
|
||||
The PWA manifest requires 8 icon sizes for full compatibility across devices.
|
||||
|
||||
## Required Icon Sizes
|
||||
|
||||
- 72x72px
|
||||
- 96x96px
|
||||
- 128x128px
|
||||
- 144x144px
|
||||
- 152x152px
|
||||
- 192x192px
|
||||
- 384x384px
|
||||
- 512x512px (maskable)
|
||||
|
||||
## Design Guidelines
|
||||
|
||||
**Background:** Navy blue (#142a66)
|
||||
**Icon/Logo:** Orange (#f48b1c)
|
||||
**Style:** Simple, recognizable design that works at small sizes
|
||||
|
||||
## Quick Generation Methods
|
||||
|
||||
### Option 1: Online PWA Icon Generator
|
||||
|
||||
1. Visit: https://www.pwabuilder.com/imageGenerator
|
||||
2. Upload a 512x512px source image
|
||||
3. Download the generated icon pack
|
||||
4. Copy PNG files to this directory
|
||||
|
||||
### Option 2: ImageMagick (Command Line)
|
||||
|
||||
If you have a 512x512px source image called `source-icon.png`:
|
||||
|
||||
```bash
|
||||
# From the icons directory
|
||||
for size in 72 96 128 144 152 192 384 512; do
|
||||
convert source-icon.png -resize ${size}x${size} icon-${size}.png
|
||||
done
|
||||
```
|
||||
|
||||
### Option 3: Photoshop/GIMP
|
||||
|
||||
1. Create a 512x512px canvas
|
||||
2. Add your design (navy background + orange icon)
|
||||
3. Save/Export for each required size
|
||||
4. Name files as: icon-72.png, icon-96.png, etc.
|
||||
|
||||
## Temporary Placeholder
|
||||
|
||||
For testing, you can use a simple colored square:
|
||||
|
||||
```bash
|
||||
# Generate simple colored placeholder icons
|
||||
for size in 72 96 128 144 152 192 384 512; do
|
||||
convert -size ${size}x${size} xc:#142a66 \
|
||||
-gravity center \
|
||||
-fill '#f48b1c' \
|
||||
-pointsize $((size / 2)) \
|
||||
-annotate +0+0 'SFM' \
|
||||
icon-${size}.png
|
||||
done
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After generating icons, verify:
|
||||
- All 8 sizes exist in this directory
|
||||
- Files are named exactly: icon-72.png, icon-96.png, etc.
|
||||
- Images have transparent or navy background
|
||||
- Logo/text is clearly visible at smallest size (72px)
|
||||
|
||||
## Testing PWA Installation
|
||||
|
||||
1. Open SFM in Chrome on Android or Safari on iOS
|
||||
2. Look for "Install App" or "Add to Home Screen" prompt
|
||||
3. Check that the correct icon appears in the install dialog
|
||||
4. After installation, verify icon on home screen
|
||||
BIN
app/ui/static/icons/icon-128.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
4
app/ui/static/icons/icon-128.png.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="128" height="128" fill="#142a66"/>
|
||||
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="128" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-144.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
4
app/ui/static/icons/icon-144.png.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="144" height="144" fill="#142a66"/>
|
||||
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 287 B |
BIN
app/ui/static/icons/icon-152.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
4
app/ui/static/icons/icon-152.png.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="152" height="152" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="152" height="152" fill="#142a66"/>
|
||||
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="152" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
4
app/ui/static/icons/icon-192.png.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="192" height="192" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="192" height="192" fill="#142a66"/>
|
||||
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="192" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-384.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
4
app/ui/static/icons/icon-384.png.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="384" height="384" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="384" height="384" fill="#142a66"/>
|
||||
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="38" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 287 B |
BIN
app/ui/static/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
4
app/ui/static/icons/icon-512.png.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" fill="#142a66"/>
|
||||
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="512" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-72.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
4
app/ui/static/icons/icon-72.png.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="72" height="72" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="72" height="72" fill="#142a66"/>
|
||||
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="72" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 283 B |
BIN
app/ui/static/icons/icon-96.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
4
app/ui/static/icons/icon-96.png.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="96" height="96" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="96" height="96" fill="#142a66"/>
|
||||
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="96" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 283 B |
78
app/ui/static/manifest.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "Seismo Fleet Manager",
|
||||
"short_name": "SFM",
|
||||
"description": "Real-time seismograph and modem fleet monitoring and management",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#142a66",
|
||||
"theme_color": "#f48b1c",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/static/screenshots/dashboard.png",
|
||||
"type": "image/png",
|
||||
"sizes": "540x720",
|
||||
"form_factor": "narrow"
|
||||
}
|
||||
],
|
||||
"categories": ["utilities", "productivity"],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Dashboard",
|
||||
"short_name": "Dashboard",
|
||||
"description": "View fleet status dashboard",
|
||||
"url": "/",
|
||||
"icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Fleet Roster",
|
||||
"short_name": "Roster",
|
||||
"description": "View and manage fleet roster",
|
||||
"url": "/roster",
|
||||
"icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
612
app/ui/static/mobile.css
Normal file
@@ -0,0 +1,612 @@
|
||||
/* Mobile-specific styles for Seismo Fleet Manager */
|
||||
/* Touch-optimized, portrait-first design */
|
||||
|
||||
/* ===== MOBILE TOUCH TARGETS ===== */
|
||||
@media (max-width: 767px) {
|
||||
/* Buttons - 44x44px minimum (iOS standard) */
|
||||
.btn, button:not(.tab-button), .button, a.button {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Icon-only buttons */
|
||||
.icon-button, .btn-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Form inputs - 48px height, 16px font prevents iOS zoom */
|
||||
input:not([type="checkbox"]):not([type="radio"]),
|
||||
select,
|
||||
textarea {
|
||||
min-height: 48px;
|
||||
font-size: 16px !important;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Checkboxes and radio buttons - larger touch targets */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
/* Bottom nav buttons - 56px industry standard */
|
||||
.bottom-nav button {
|
||||
min-height: 56px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Increase spacing between clickable elements */
|
||||
.btn + .btn,
|
||||
button + button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== HAMBURGER MENU ===== */
|
||||
.hamburger-btn {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 50;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.hamburger-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.dark .hamburger-btn {
|
||||
background-color: #1e293b;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
/* Hamburger icon */
|
||||
.hamburger-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.hamburger-line {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: #374151;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.dark .hamburger-line {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Hamburger animation when menu open */
|
||||
.menu-open .hamburger-line:nth-child(1) {
|
||||
transform: translateY(8px) rotate(45deg);
|
||||
}
|
||||
|
||||
.menu-open .hamburger-line:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.menu-open .hamburger-line:nth-child(3) {
|
||||
transform: translateY(-8px) rotate(-45deg);
|
||||
}
|
||||
|
||||
/* ===== SIDEBAR (RESPONSIVE) ===== */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 16rem; /* 256px */
|
||||
height: 100vh;
|
||||
z-index: 40;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== BACKDROP ===== */
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 30;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.backdrop.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.backdrop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== BOTTOM NAVIGATION ===== */
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4rem;
|
||||
background-color: white;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
z-index: 20;
|
||||
box-shadow: 0 -1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.dark .bottom-nav {
|
||||
background-color: #1e293b;
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
.bottom-nav-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bottom-nav-btn:active {
|
||||
transform: scale(0.95);
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.dark .bottom-nav-btn:active {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.bottom-nav-btn.active {
|
||||
color: #f48b1c; /* seismo-orange */
|
||||
}
|
||||
|
||||
.bottom-nav-btn svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.bottom-nav-btn span {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== MAIN CONTENT ADJUSTMENTS ===== */
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
padding-bottom: 5rem; /* 80px for bottom nav */
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.main-content {
|
||||
margin-left: 16rem; /* 256px sidebar width */
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== MOBILE ROSTER CARDS ===== */
|
||||
.unit-card {
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.unit-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.dark .unit-card {
|
||||
background-color: #1e293b;
|
||||
}
|
||||
|
||||
.unit-card:hover {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* ===== UNIT DETAIL MODAL (BOTTOM SHEET) ===== */
|
||||
.unit-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.unit-modal.show {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.unit-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.unit-modal-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
background-color: white;
|
||||
border-top-left-radius: 1rem;
|
||||
border-top-right-radius: 1rem;
|
||||
box-shadow: 0 -4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
overflow-y: auto;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
.unit-modal.show .unit-modal-content {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dark .unit-modal-content {
|
||||
background-color: #1e293b;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.unit-modal {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.unit-modal-content {
|
||||
max-width: 42rem; /* 672px */
|
||||
border-radius: 0.75rem;
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.unit-modal.show .unit-modal-content {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal handle bar (mobile only) */
|
||||
.modal-handle {
|
||||
height: 4px;
|
||||
width: 3rem;
|
||||
background-color: #d1d5db;
|
||||
border-radius: 9999px;
|
||||
margin: 0.75rem auto 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.modal-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== OFFLINE INDICATOR ===== */
|
||||
.offline-indicator {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #eab308; /* yellow-500 */
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
z-index: 50;
|
||||
transform: translateY(-100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.offline-indicator.show {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ===== SYNC TOAST ===== */
|
||||
.sync-toast {
|
||||
position: fixed;
|
||||
bottom: 6rem; /* Above bottom nav */
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
background-color: #22c55e; /* green-500 */
|
||||
color: white;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
z-index: 50;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sync-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.sync-toast {
|
||||
bottom: 1rem;
|
||||
left: auto;
|
||||
right: 1rem;
|
||||
max-width: 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== MOBILE SEARCH BAR (STICKY) ===== */
|
||||
@media (max-width: 767px) {
|
||||
.mobile-search-sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: #f3f4f6;
|
||||
margin: -1rem -1rem 1rem -1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.dark .mobile-search-sticky {
|
||||
background-color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mobile-search-sticky {
|
||||
position: static;
|
||||
background-color: transparent;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== STATUS BADGES ===== */
|
||||
.status-dot {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== DEVICE TYPE BADGES ===== */
|
||||
.device-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ===== MOBILE MAP HEIGHT ===== */
|
||||
@media (max-width: 767px) {
|
||||
#fleet-map {
|
||||
height: 16rem !important; /* 256px on mobile */
|
||||
}
|
||||
|
||||
#unit-map {
|
||||
height: 16rem !important; /* 256px on mobile */
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== MAP OVERLAP FIX ===== */
|
||||
/* Prevent map and controls from overlapping UI elements on mobile */
|
||||
@media (max-width: 767px) {
|
||||
/* Constrain leaflet container to prevent overflow */
|
||||
.leaflet-container {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Override Leaflet's default high z-index values */
|
||||
/* Bottom nav is z-20, sidebar is z-40, so map must be below */
|
||||
.leaflet-pane,
|
||||
.leaflet-tile-pane,
|
||||
.leaflet-overlay-pane,
|
||||
.leaflet-shadow-pane,
|
||||
.leaflet-marker-pane,
|
||||
.leaflet-tooltip-pane,
|
||||
.leaflet-popup-pane {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
/* Map controls should also be below navigation elements */
|
||||
.leaflet-control-container,
|
||||
.leaflet-top,
|
||||
.leaflet-bottom,
|
||||
.leaflet-left,
|
||||
.leaflet-right {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
.leaflet-control {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
/* When sidebar is open, hide all Leaflet controls (zoom, attribution, etc) */
|
||||
body.menu-open .leaflet-control-container {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Ensure map tiles are non-interactive when sidebar is open */
|
||||
body.menu-open #fleet-map,
|
||||
body.menu-open #unit-map {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== PENDING SYNC BADGE ===== */
|
||||
.pending-sync-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background-color: #fef3c7; /* amber-100 */
|
||||
color: #92400e; /* amber-800 */
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dark .pending-sync-badge {
|
||||
background-color: #78350f;
|
||||
color: #fef3c7;
|
||||
}
|
||||
|
||||
.pending-sync-badge::before {
|
||||
content: "⏳";
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ===== MOBILE-SPECIFIC UTILITY CLASSES ===== */
|
||||
@media (max-width: 767px) {
|
||||
.mobile-text-lg {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.mobile-text-xl {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.mobile-p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mobile-mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== ACCESSIBILITY ===== */
|
||||
/* Improve focus visibility on mobile */
|
||||
@media (max-width: 767px) {
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid #f48b1c;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent text selection on buttons (better mobile UX) */
|
||||
button,
|
||||
.btn,
|
||||
.button {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* ===== SMOOTH SCROLLING ===== */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Prevent overscroll bounce on iOS */
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
/* ===== LOADING STATES ===== */
|
||||
.loading-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== SAFE AREA SUPPORT (iOS notch) ===== */
|
||||
@supports (padding: env(safe-area-inset-bottom)) {
|
||||
.bottom-nav {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
height: calc(4rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding-bottom: calc(5rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.main-content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
597
app/ui/static/mobile.js
Normal file
@@ -0,0 +1,597 @@
|
||||
/* Mobile JavaScript for Seismo Fleet Manager */
|
||||
/* Handles hamburger menu, modals, offline sync, and mobile interactions */
|
||||
|
||||
// ===== GLOBAL STATE =====
|
||||
let currentUnitData = null;
|
||||
let isOnline = navigator.onLine;
|
||||
|
||||
// ===== HAMBURGER MENU TOGGLE =====
|
||||
function toggleMenu() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const backdrop = document.getElementById('backdrop');
|
||||
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
||||
|
||||
if (sidebar && backdrop) {
|
||||
const isOpen = sidebar.classList.contains('open');
|
||||
|
||||
if (isOpen) {
|
||||
// Close menu
|
||||
sidebar.classList.remove('open');
|
||||
backdrop.classList.remove('show');
|
||||
hamburgerBtn?.classList.remove('menu-open');
|
||||
document.body.style.overflow = '';
|
||||
document.body.classList.remove('menu-open');
|
||||
} else {
|
||||
// Open menu
|
||||
sidebar.classList.add('open');
|
||||
backdrop.classList.add('show');
|
||||
hamburgerBtn?.classList.add('menu-open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.classList.add('menu-open');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close menu when clicking backdrop
|
||||
function closeMenuFromBackdrop() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const backdrop = document.getElementById('backdrop');
|
||||
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
||||
|
||||
if (sidebar && backdrop) {
|
||||
sidebar.classList.remove('open');
|
||||
backdrop.classList.remove('show');
|
||||
hamburgerBtn?.classList.remove('menu-open');
|
||||
document.body.style.overflow = '';
|
||||
document.body.classList.remove('menu-open');
|
||||
}
|
||||
}
|
||||
|
||||
// Close menu when window is resized to desktop
|
||||
function handleResize() {
|
||||
if (window.innerWidth >= 768) {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const backdrop = document.getElementById('backdrop');
|
||||
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
||||
|
||||
if (sidebar && backdrop) {
|
||||
sidebar.classList.remove('open');
|
||||
backdrop.classList.remove('show');
|
||||
hamburgerBtn?.classList.remove('menu-open');
|
||||
document.body.style.overflow = '';
|
||||
document.body.classList.remove('menu-open');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== UNIT DETAIL MODAL =====
|
||||
function openUnitModal(unitId, status = null, age = null) {
|
||||
const modal = document.getElementById('unitModal');
|
||||
if (!modal) return;
|
||||
|
||||
// Store the status info passed from the card
|
||||
// Accept status if it's a non-empty string, use age if provided or default to '--'
|
||||
const cardStatusInfo = (status && status !== '') ? {
|
||||
status: status,
|
||||
age: age || '--'
|
||||
} : null;
|
||||
|
||||
console.log('openUnitModal:', { unitId, status, age, cardStatusInfo });
|
||||
|
||||
// Fetch unit data and populate modal
|
||||
fetchUnitDetails(unitId).then(unit => {
|
||||
if (unit) {
|
||||
currentUnitData = unit;
|
||||
// Pass the card status info to the populate function
|
||||
populateUnitModal(unit, cardStatusInfo);
|
||||
modal.classList.add('show');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeUnitModal(event) {
|
||||
// Only close if clicking backdrop or close button
|
||||
if (event && event.target.closest('.unit-modal-content') && !event.target.closest('[data-close-modal]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('unitModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('show');
|
||||
document.body.style.overflow = '';
|
||||
currentUnitData = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUnitDetails(unitId) {
|
||||
try {
|
||||
// Try to fetch from network first
|
||||
const response = await fetch(`/api/roster/${unitId}`);
|
||||
if (response.ok) {
|
||||
const unit = await response.json();
|
||||
|
||||
// Save to IndexedDB if offline support is available
|
||||
if (window.offlineDB) {
|
||||
await window.offlineDB.saveUnit(unit);
|
||||
}
|
||||
|
||||
return unit;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Network fetch failed, trying offline storage:', error);
|
||||
|
||||
// Fall back to offline storage
|
||||
if (window.offlineDB) {
|
||||
return await window.offlineDB.getUnit(unitId);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function populateUnitModal(unit, cardStatusInfo = null) {
|
||||
// Set unit ID in header
|
||||
const modalUnitId = document.getElementById('modalUnitId');
|
||||
if (modalUnitId) {
|
||||
modalUnitId.textContent = unit.id;
|
||||
}
|
||||
|
||||
// Populate modal content
|
||||
const modalContent = document.getElementById('modalContent');
|
||||
if (!modalContent) return;
|
||||
|
||||
// Use status from card if provided, otherwise get from snapshot or derive from unit
|
||||
let statusInfo = cardStatusInfo || getUnitStatus(unit.id, unit);
|
||||
console.log('populateUnitModal:', { unit, cardStatusInfo, statusInfo });
|
||||
|
||||
const statusColor = statusInfo.status === 'OK' ? 'green' :
|
||||
statusInfo.status === 'Pending' ? 'yellow' :
|
||||
statusInfo.status === 'Missing' ? 'red' : 'gray';
|
||||
|
||||
const statusTextColor = statusInfo.status === 'OK' ? 'text-green-600 dark:text-green-400' :
|
||||
statusInfo.status === 'Pending' ? 'text-yellow-600 dark:text-yellow-400' :
|
||||
statusInfo.status === 'Missing' ? 'text-red-600 dark:text-red-400' :
|
||||
'text-gray-600 dark:text-gray-400';
|
||||
|
||||
// Determine status label (show "Benched" instead of "Unknown" for non-deployed units)
|
||||
let statusLabel = statusInfo.status;
|
||||
if ((statusInfo.status === 'Unknown' || statusInfo.status === 'N/A') && !unit.deployed) {
|
||||
statusLabel = 'Benched';
|
||||
}
|
||||
|
||||
// Create navigation URL for location
|
||||
const createNavUrl = (address, coordinates) => {
|
||||
if (address) {
|
||||
// Use address for navigation
|
||||
const encodedAddress = encodeURIComponent(address);
|
||||
// Universal link that works on iOS and Android
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodedAddress}`;
|
||||
} else if (coordinates) {
|
||||
// Use coordinates for navigation (format: lat,lon)
|
||||
const encodedCoords = encodeURIComponent(coordinates);
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodedCoords}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const navUrl = createNavUrl(unit.address, unit.coordinates);
|
||||
|
||||
modalContent.innerHTML = `
|
||||
<!-- Status Section -->
|
||||
<div class="flex items-center justify-between pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 rounded-full bg-${statusColor}-500"></span>
|
||||
<span class="font-semibold ${statusTextColor}">${statusLabel}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">${statusInfo.age || '--'}</span>
|
||||
</div>
|
||||
|
||||
<!-- Device Info -->
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Device Type</label>
|
||||
<p class="mt-1 text-gray-900 dark:text-white">${unit.device_type || '--'}</p>
|
||||
</div>
|
||||
|
||||
${unit.unit_type ? `
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Unit Type</label>
|
||||
<p class="mt-1 text-gray-900 dark:text-white">${unit.unit_type}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${unit.project_id ? `
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Project ID</label>
|
||||
<p class="mt-1 text-gray-900 dark:text-white">${unit.project_id}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${unit.address ? `
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Address</label>
|
||||
${navUrl ? `
|
||||
<a href="${navUrl}" target="_blank" class="mt-1 flex items-center gap-2 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<span class="underline">${unit.address}</span>
|
||||
</a>
|
||||
` : `
|
||||
<p class="mt-1 text-gray-900 dark:text-white">${unit.address}</p>
|
||||
`}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${unit.coordinates && !unit.address ? `
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Coordinates</label>
|
||||
${navUrl ? `
|
||||
<a href="${navUrl}" target="_blank" class="mt-1 flex items-center gap-2 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<span class="font-mono text-sm underline">${unit.coordinates}</span>
|
||||
</a>
|
||||
` : `
|
||||
<p class="mt-1 text-gray-900 dark:text-white font-mono text-sm">${unit.coordinates}</p>
|
||||
`}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Seismograph-specific fields -->
|
||||
${unit.device_type === 'seismograph' ? `
|
||||
${unit.last_calibrated ? `
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Last Calibrated</label>
|
||||
<p class="mt-1 text-gray-900 dark:text-white">${unit.last_calibrated}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${unit.next_calibration_due ? `
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Next Calibration Due</label>
|
||||
<p class="mt-1 text-gray-900 dark:text-white">${unit.next_calibration_due}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${unit.deployed_with_modem_id ? `
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
|
||||
<p class="mt-1 text-gray-900 dark:text-white">${unit.deployed_with_modem_id}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
` : ''}
|
||||
|
||||
<!-- Modem-specific fields -->
|
||||
${unit.device_type === 'modem' ? `
|
||||
${unit.ip_address ? `
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">IP Address</label>
|
||||
<p class="mt-1 text-gray-900 dark:text-white">${unit.ip_address}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${unit.phone_number ? `
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Phone Number</label>
|
||||
<p class="mt-1 text-gray-900 dark:text-white">${unit.phone_number}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${unit.hardware_model ? `
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Hardware Model</label>
|
||||
<p class="mt-1 text-gray-900 dark:text-white">${unit.hardware_model}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
` : ''}
|
||||
|
||||
${unit.note ? `
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
||||
<p class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">${unit.note}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Deployed</label>
|
||||
<p class="mt-1 text-gray-900 dark:text-white">${unit.deployed ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Retired</label>
|
||||
<p class="mt-1 text-gray-900 dark:text-white">${unit.retired ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Update action buttons
|
||||
const editBtn = document.getElementById('modalEditBtn');
|
||||
const deployBtn = document.getElementById('modalDeployBtn');
|
||||
const deleteBtn = document.getElementById('modalDeleteBtn');
|
||||
|
||||
if (editBtn) {
|
||||
editBtn.onclick = () => {
|
||||
window.location.href = `/unit/${unit.id}`;
|
||||
};
|
||||
}
|
||||
|
||||
if (deployBtn) {
|
||||
deployBtn.textContent = unit.deployed ? 'Bench Unit' : 'Deploy Unit';
|
||||
deployBtn.onclick = () => toggleDeployStatus(unit.id, !unit.deployed);
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
deleteBtn.onclick = () => deleteUnit(unit.id);
|
||||
}
|
||||
}
|
||||
|
||||
function getUnitStatus(unitId, unit = null) {
|
||||
// Prefer roster table data if it was rendered with the current view
|
||||
if (window.rosterStatusMap && window.rosterStatusMap[unitId]) {
|
||||
return window.rosterStatusMap[unitId];
|
||||
}
|
||||
|
||||
// Try to get status from dashboard snapshot if it exists
|
||||
if (window.lastStatusSnapshot && window.lastStatusSnapshot.units && window.lastStatusSnapshot.units[unitId]) {
|
||||
const unitStatus = window.lastStatusSnapshot.units[unitId];
|
||||
return {
|
||||
status: unitStatus.status,
|
||||
age: unitStatus.age,
|
||||
last: unitStatus.last
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: if unit data is provided, derive status from deployment state
|
||||
if (unit) {
|
||||
if (unit.deployed) {
|
||||
// For deployed units without status data, default to "Unknown"
|
||||
return { status: 'Unknown', age: '--', last: '--' };
|
||||
} else {
|
||||
// For benched units, use "N/A" which will be displayed as "Benched"
|
||||
return { status: 'N/A', age: '--', last: '--' };
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'Unknown', age: '--', last: '--' };
|
||||
}
|
||||
|
||||
async function toggleDeployStatus(unitId, deployed) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('deployed', deployed ? 'true' : 'false');
|
||||
|
||||
const response = await fetch(`/api/roster/edit/${unitId}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast('✓ Unit updated successfully');
|
||||
closeUnitModal();
|
||||
|
||||
// Trigger HTMX refresh if on roster page
|
||||
const rosterTable = document.querySelector('[hx-get*="roster"]');
|
||||
if (rosterTable) {
|
||||
htmx.trigger(rosterTable, 'refresh');
|
||||
}
|
||||
} else {
|
||||
showToast('❌ Failed to update unit', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling deploy status:', error);
|
||||
showToast('❌ Failed to update unit', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUnit(unitId) {
|
||||
if (!confirm(`Are you sure you want to delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/roster/${unitId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast('✓ Unit deleted successfully');
|
||||
closeUnitModal();
|
||||
|
||||
// Refresh roster page if present
|
||||
const rosterTable = document.querySelector('[hx-get*="roster"]');
|
||||
if (rosterTable) {
|
||||
htmx.trigger(rosterTable, 'refresh');
|
||||
}
|
||||
} else {
|
||||
showToast('❌ Failed to delete unit', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting unit:', error);
|
||||
showToast('❌ Failed to delete unit', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ONLINE/OFFLINE STATUS =====
|
||||
function updateOnlineStatus() {
|
||||
isOnline = navigator.onLine;
|
||||
const offlineIndicator = document.getElementById('offlineIndicator');
|
||||
|
||||
if (offlineIndicator) {
|
||||
if (isOnline) {
|
||||
offlineIndicator.classList.remove('show');
|
||||
// Trigger sync when coming back online
|
||||
if (window.offlineDB) {
|
||||
syncPendingEdits();
|
||||
}
|
||||
} else {
|
||||
offlineIndicator.classList.add('show');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('online', updateOnlineStatus);
|
||||
window.addEventListener('offline', updateOnlineStatus);
|
||||
|
||||
// ===== SYNC FUNCTIONALITY =====
|
||||
async function syncPendingEdits() {
|
||||
if (!window.offlineDB) return;
|
||||
|
||||
try {
|
||||
const pendingEdits = await window.offlineDB.getPendingEdits();
|
||||
|
||||
if (pendingEdits.length === 0) return;
|
||||
|
||||
console.log(`Syncing ${pendingEdits.length} pending edits...`);
|
||||
|
||||
for (const edit of pendingEdits) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of Object.entries(edit.changes)) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/roster/edit/${edit.unitId}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await window.offlineDB.clearEdit(edit.id);
|
||||
console.log(`Synced edit ${edit.id} for unit ${edit.unitId}`);
|
||||
} else {
|
||||
console.error(`Failed to sync edit ${edit.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error syncing edit ${edit.id}:`, error);
|
||||
// Keep in queue for next sync attempt
|
||||
}
|
||||
}
|
||||
|
||||
// Show success toast
|
||||
showToast('✓ Synced successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in syncPendingEdits:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Manual sync button
|
||||
function manualSync() {
|
||||
if (!isOnline) {
|
||||
showToast('⚠️ Cannot sync while offline', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
syncPendingEdits();
|
||||
}
|
||||
|
||||
// ===== TOAST NOTIFICATIONS =====
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.getElementById('syncToast');
|
||||
if (!toast) return;
|
||||
|
||||
// Update toast appearance based on type
|
||||
toast.classList.remove('bg-green-500', 'bg-red-500', 'bg-yellow-500');
|
||||
|
||||
if (type === 'success') {
|
||||
toast.classList.add('bg-green-500');
|
||||
} else if (type === 'error') {
|
||||
toast.classList.add('bg-red-500');
|
||||
} else if (type === 'warning') {
|
||||
toast.classList.add('bg-yellow-500');
|
||||
}
|
||||
|
||||
toast.textContent = message;
|
||||
toast.classList.add('show');
|
||||
|
||||
// Auto-hide after 3 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ===== BOTTOM NAV ACTIVE STATE =====
|
||||
function updateBottomNavActiveState() {
|
||||
const currentPath = window.location.pathname;
|
||||
const navButtons = document.querySelectorAll('.bottom-nav-btn');
|
||||
|
||||
navButtons.forEach(btn => {
|
||||
const href = btn.getAttribute('data-href');
|
||||
if (href && (currentPath === href || (href !== '/' && currentPath.startsWith(href)))) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== INITIALIZATION =====
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize online/offline status
|
||||
updateOnlineStatus();
|
||||
|
||||
// Update bottom nav active state
|
||||
updateBottomNavActiveState();
|
||||
|
||||
// Add resize listener
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Close menu on navigation (for mobile)
|
||||
document.addEventListener('click', (e) => {
|
||||
const link = e.target.closest('a');
|
||||
if (link && link.closest('#sidebar')) {
|
||||
// Delay to allow navigation to start
|
||||
setTimeout(() => {
|
||||
if (window.innerWidth < 768) {
|
||||
closeMenuFromBackdrop();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent scroll when modals are open (iOS fix)
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
const modal = document.querySelector('.unit-modal.show, #sidebar.open');
|
||||
if (modal && !e.target.closest('.unit-modal-content, #sidebar')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
console.log('Mobile.js initialized');
|
||||
});
|
||||
|
||||
// ===== SERVICE WORKER REGISTRATION =====
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('Service Worker registered:', registration);
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update();
|
||||
}, 60 * 60 * 1000); // Check every hour
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Service Worker registration failed:', error);
|
||||
});
|
||||
|
||||
// Listen for service worker updates
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
console.log('Service Worker updated, reloading page...');
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
// Export functions for global use
|
||||
window.toggleMenu = toggleMenu;
|
||||
window.closeMenuFromBackdrop = closeMenuFromBackdrop;
|
||||
window.openUnitModal = openUnitModal;
|
||||
window.closeUnitModal = closeUnitModal;
|
||||
window.manualSync = manualSync;
|
||||
window.showToast = showToast;
|
||||
349
app/ui/static/offline-db.js
Normal file
@@ -0,0 +1,349 @@
|
||||
/* IndexedDB wrapper for offline data storage in SFM */
|
||||
/* Handles unit data, status snapshots, and pending edit queue */
|
||||
|
||||
class OfflineDB {
|
||||
constructor() {
|
||||
this.dbName = 'sfm-offline-db';
|
||||
this.version = 1;
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
async init() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.version);
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('IndexedDB error:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
console.log('IndexedDB initialized successfully');
|
||||
resolve(this.db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
|
||||
// Units store - full unit details
|
||||
if (!db.objectStoreNames.contains('units')) {
|
||||
const unitsStore = db.createObjectStore('units', { keyPath: 'id' });
|
||||
unitsStore.createIndex('device_type', 'device_type', { unique: false });
|
||||
unitsStore.createIndex('deployed', 'deployed', { unique: false });
|
||||
console.log('Created units object store');
|
||||
}
|
||||
|
||||
// Status snapshot store - latest status data
|
||||
if (!db.objectStoreNames.contains('status-snapshot')) {
|
||||
db.createObjectStore('status-snapshot', { keyPath: 'timestamp' });
|
||||
console.log('Created status-snapshot object store');
|
||||
}
|
||||
|
||||
// Pending edits store - offline edit queue
|
||||
if (!db.objectStoreNames.contains('pending-edits')) {
|
||||
const editsStore = db.createObjectStore('pending-edits', {
|
||||
keyPath: 'id',
|
||||
autoIncrement: true
|
||||
});
|
||||
editsStore.createIndex('unitId', 'unitId', { unique: false });
|
||||
editsStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
console.log('Created pending-edits object store');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ===== UNITS OPERATIONS =====
|
||||
|
||||
// Save or update a unit
|
||||
async saveUnit(unit) {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['units'], 'readwrite');
|
||||
const store = transaction.objectStore('units');
|
||||
const request = store.put(unit);
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Get a single unit by ID
|
||||
async getUnit(unitId) {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['units'], 'readonly');
|
||||
const store = transaction.objectStore('units');
|
||||
const request = store.get(unitId);
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Get all units
|
||||
async getAllUnits() {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['units'], 'readonly');
|
||||
const store = transaction.objectStore('units');
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Delete a unit
|
||||
async deleteUnit(unitId) {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['units'], 'readwrite');
|
||||
const store = transaction.objectStore('units');
|
||||
const request = store.delete(unitId);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== STATUS SNAPSHOT OPERATIONS =====
|
||||
|
||||
// Save status snapshot
|
||||
async saveSnapshot(snapshot) {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['status-snapshot'], 'readwrite');
|
||||
const store = transaction.objectStore('status-snapshot');
|
||||
|
||||
// Add timestamp
|
||||
const snapshotWithTimestamp = {
|
||||
...snapshot,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
const request = store.put(snapshotWithTimestamp);
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Get latest status snapshot
|
||||
async getLatestSnapshot() {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['status-snapshot'], 'readonly');
|
||||
const store = transaction.objectStore('status-snapshot');
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const snapshots = request.result;
|
||||
if (snapshots.length > 0) {
|
||||
// Return the most recent snapshot
|
||||
const latest = snapshots.reduce((prev, current) =>
|
||||
(prev.timestamp > current.timestamp) ? prev : current
|
||||
);
|
||||
resolve(latest);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Clear old snapshots (keep only latest)
|
||||
async clearOldSnapshots() {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const transaction = this.db.transaction(['status-snapshot'], 'readwrite');
|
||||
const store = transaction.objectStore('status-snapshot');
|
||||
const getAllRequest = store.getAll();
|
||||
|
||||
getAllRequest.onsuccess = () => {
|
||||
const snapshots = getAllRequest.result;
|
||||
|
||||
if (snapshots.length > 1) {
|
||||
// Sort by timestamp, keep only the latest
|
||||
snapshots.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
// Delete all except the first (latest)
|
||||
for (let i = 1; i < snapshots.length; i++) {
|
||||
store.delete(snapshots[i].timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
getAllRequest.onerror = () => reject(getAllRequest.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== PENDING EDITS OPERATIONS =====
|
||||
|
||||
// Queue an edit for offline sync
|
||||
async queueEdit(unitId, changes) {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['pending-edits'], 'readwrite');
|
||||
const store = transaction.objectStore('pending-edits');
|
||||
|
||||
const edit = {
|
||||
unitId,
|
||||
changes,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
const request = store.add(edit);
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log(`Queued edit for unit ${unitId}`);
|
||||
resolve(request.result);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Get all pending edits
|
||||
async getPendingEdits() {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['pending-edits'], 'readonly');
|
||||
const store = transaction.objectStore('pending-edits');
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Get pending edits count
|
||||
async getPendingEditsCount() {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['pending-edits'], 'readonly');
|
||||
const store = transaction.objectStore('pending-edits');
|
||||
const request = store.count();
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Clear a synced edit
|
||||
async clearEdit(editId) {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['pending-edits'], 'readwrite');
|
||||
const store = transaction.objectStore('pending-edits');
|
||||
const request = store.delete(editId);
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log(`Cleared edit ${editId} from queue`);
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all pending edits
|
||||
async clearAllEdits() {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['pending-edits'], 'readwrite');
|
||||
const store = transaction.objectStore('pending-edits');
|
||||
const request = store.clear();
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log('Cleared all pending edits');
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== UTILITY OPERATIONS =====
|
||||
|
||||
// Clear all data (for debugging/reset)
|
||||
async clearAllData() {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const storeNames = ['units', 'status-snapshot', 'pending-edits'];
|
||||
const transaction = this.db.transaction(storeNames, 'readwrite');
|
||||
|
||||
storeNames.forEach(storeName => {
|
||||
transaction.objectStore(storeName).clear();
|
||||
});
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
console.log('Cleared all offline data');
|
||||
resolve();
|
||||
};
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Get database statistics
|
||||
async getStats() {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
const unitsCount = await new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['units'], 'readonly');
|
||||
const request = transaction.objectStore('units').count();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
const pendingEditsCount = await this.getPendingEditsCount();
|
||||
|
||||
const hasSnapshot = await new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(['status-snapshot'], 'readonly');
|
||||
const request = transaction.objectStore('status-snapshot').count();
|
||||
request.onsuccess = () => resolve(request.result > 0);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
return {
|
||||
unitsCount,
|
||||
pendingEditsCount,
|
||||
hasSnapshot
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
window.offlineDB = new OfflineDB();
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
await window.offlineDB.init();
|
||||
console.log('Offline database ready');
|
||||
|
||||
// Display pending edits count if any
|
||||
const pendingCount = await window.offlineDB.getPendingEditsCount();
|
||||
if (pendingCount > 0) {
|
||||
console.log(`${pendingCount} pending edits in queue`);
|
||||
// Could show a badge in the UI here
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize offline database:', error);
|
||||
}
|
||||
});
|
||||
12
app/ui/static/style.css
Normal file
@@ -0,0 +1,12 @@
|
||||
/* Custom styles for Seismo Fleet Manager */
|
||||
|
||||
/* Additional custom styles can go here */
|
||||
|
||||
.card-hover {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
347
app/ui/static/sw.js
Normal file
@@ -0,0 +1,347 @@
|
||||
/* Service Worker for Seismo Fleet Manager PWA */
|
||||
/* Network-first strategy with cache fallback for real-time data */
|
||||
|
||||
const CACHE_VERSION = 'v1';
|
||||
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
|
||||
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
|
||||
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
|
||||
|
||||
// Files to precache (critical app shell)
|
||||
const STATIC_FILES = [
|
||||
'/',
|
||||
'/static/style.css',
|
||||
'/static/mobile.css',
|
||||
'/static/mobile.js',
|
||||
'/static/offline-db.js',
|
||||
'/static/manifest.json',
|
||||
'https://cdn.tailwindcss.com',
|
||||
'https://unpkg.com/htmx.org@1.9.10',
|
||||
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',
|
||||
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
|
||||
];
|
||||
|
||||
// Install event - cache static files
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing service worker...');
|
||||
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE)
|
||||
.then((cache) => {
|
||||
console.log('[SW] Precaching static files');
|
||||
return cache.addAll(STATIC_FILES);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('[SW] Static files cached successfully');
|
||||
return self.skipWaiting(); // Activate immediately
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[SW] Precaching failed:', error);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating service worker...');
|
||||
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
// Delete old caches that don't match current version
|
||||
if (cacheName !== STATIC_CACHE &&
|
||||
cacheName !== DYNAMIC_CACHE &&
|
||||
cacheName !== DATA_CACHE) {
|
||||
console.log('[SW] Deleting old cache:', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('[SW] Service worker activated');
|
||||
return self.clients.claim(); // Take control of all pages
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - network-first strategy
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip chrome-extension and other non-http(s) requests
|
||||
if (!url.protocol.startsWith('http')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// API requests - network first, cache fallback
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(networkFirstStrategy(request, DATA_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets - cache first
|
||||
if (isStaticAsset(url.pathname)) {
|
||||
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML pages - network first with cache fallback
|
||||
if (request.headers.get('accept')?.includes('text/html')) {
|
||||
event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else - network first
|
||||
event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE));
|
||||
});
|
||||
|
||||
// Network-first strategy
|
||||
async function networkFirstStrategy(request, cacheName) {
|
||||
try {
|
||||
// Try network first
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
// Cache successful responses
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
// Network failed, try cache
|
||||
console.log('[SW] Network failed, trying cache:', request.url);
|
||||
const cachedResponse = await caches.match(request);
|
||||
|
||||
if (cachedResponse) {
|
||||
console.log('[SW] Serving from cache:', request.url);
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// No cache available, return offline page or error
|
||||
console.error('[SW] No cache available for:', request.url);
|
||||
|
||||
// For HTML requests, return a basic offline page
|
||||
if (request.headers.get('accept')?.includes('text/html')) {
|
||||
return new Response(
|
||||
`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offline - SFM</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 { color: #f48b1c; margin-bottom: 1rem; }
|
||||
p { margin-bottom: 1.5rem; color: #6b7280; }
|
||||
button {
|
||||
background: #f48b1c;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #d97706; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📡 You're Offline</h1>
|
||||
<p>SFM requires an internet connection for this page.</p>
|
||||
<p>Please check your connection and try again.</p>
|
||||
<button onclick="location.reload()">Retry</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
{
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// For other requests, return error
|
||||
return new Response('Network error', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cache-first strategy
|
||||
async function cacheFirstStrategy(request, cacheName) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Not in cache, fetch from network
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
// Cache successful responses
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
console.error('[SW] Fetch failed:', request.url, error);
|
||||
return new Response('Network error', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if URL is a static asset
|
||||
function isStaticAsset(pathname) {
|
||||
const staticExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.svg', '.ico', '.woff', '.woff2'];
|
||||
return staticExtensions.some(ext => pathname.endsWith(ext));
|
||||
}
|
||||
|
||||
// Background Sync - for offline edits
|
||||
self.addEventListener('sync', (event) => {
|
||||
console.log('[SW] Background sync event:', event.tag);
|
||||
|
||||
if (event.tag === 'sync-edits') {
|
||||
event.waitUntil(syncPendingEdits());
|
||||
}
|
||||
});
|
||||
|
||||
// Sync pending edits to server
|
||||
async function syncPendingEdits() {
|
||||
console.log('[SW] Syncing pending edits...');
|
||||
|
||||
try {
|
||||
// Get pending edits from IndexedDB
|
||||
const db = await openDatabase();
|
||||
const edits = await getPendingEdits(db);
|
||||
|
||||
if (edits.length === 0) {
|
||||
console.log('[SW] No pending edits to sync');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SW] Syncing ${edits.length} pending edits`);
|
||||
|
||||
// Send edits to server
|
||||
const response = await fetch('/api/sync-edits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ edits })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[SW] Sync successful:', result);
|
||||
|
||||
// Clear synced edits from IndexedDB
|
||||
await clearSyncedEdits(db, result.synced_ids || []);
|
||||
|
||||
// Notify all clients about successful sync
|
||||
const clients = await self.clients.matchAll();
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'SYNC_COMPLETE',
|
||||
synced: result.synced
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.error('[SW] Sync failed:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SW] Sync error:', error);
|
||||
throw error; // Will retry sync later
|
||||
}
|
||||
}
|
||||
|
||||
// IndexedDB helpers (simplified versions - full implementations in offline-db.js)
|
||||
function openDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('sfm-offline-db', 1);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
|
||||
if (!db.objectStoreNames.contains('pending-edits')) {
|
||||
db.createObjectStore('pending-edits', { keyPath: 'id', autoIncrement: true });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getPendingEdits(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['pending-edits'], 'readonly');
|
||||
const store = transaction.objectStore('pending-edits');
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function clearSyncedEdits(db, editIds) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['pending-edits'], 'readwrite');
|
||||
const store = transaction.objectStore('pending-edits');
|
||||
|
||||
editIds.forEach(id => {
|
||||
store.delete(id);
|
||||
});
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Message event - handle messages from clients
|
||||
self.addEventListener('message', (event) => {
|
||||
console.log('[SW] Message received:', event.data);
|
||||
|
||||
if (event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
|
||||
if (event.data.type === 'CLEAR_CACHE') {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => caches.delete(cacheName))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[SW] Service Worker loaded');
|
||||
384
app/ui/templates/base.html
Normal file
@@ -0,0 +1,384 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% if environment == 'development' %}[DEV] {% endif %}{% block title %}Seismo Fleet Manager{% endblock %}</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Leaflet for maps -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<!-- Mobile CSS -->
|
||||
<link rel="stylesheet" href="/static/mobile.css">
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<meta name="theme-color" content="#f48b1c">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="SFM">
|
||||
|
||||
<!-- Custom Tailwind Config -->
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
seismo: {
|
||||
orange: '#f48b1c',
|
||||
navy: '#142a66',
|
||||
burgundy: '#7d234d',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<!-- Offline Indicator -->
|
||||
<div id="offlineIndicator" class="offline-indicator">
|
||||
📡 Offline - Changes will sync when connected
|
||||
</div>
|
||||
|
||||
<!-- Sync Toast -->
|
||||
<div id="syncToast" class="sync-toast">
|
||||
✓ Synced successfully
|
||||
</div>
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar (Responsive) -->
|
||||
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
||||
<!-- Logo -->
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h1 class="text-2xl font-bold text-seismo-navy dark:text-seismo-orange">
|
||||
Seismo<br>
|
||||
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
|
||||
</h1>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
|
||||
{% if environment == 'development' %}
|
||||
<span class="px-2 py-1 text-xs font-bold text-white bg-yellow-500 rounded">DEV</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 p-4 space-y-2">
|
||||
<a href="/" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<a href="/roster" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/roster' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
Devices
|
||||
</a>
|
||||
|
||||
<a href="/seismographs" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/seismographs' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
Seismographs
|
||||
</a>
|
||||
|
||||
<a href="/sound-level-meters" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sound-level-meters' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||
</svg>
|
||||
Sound Level Meters
|
||||
</a>
|
||||
|
||||
<a href="#" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 opacity-50 cursor-not-allowed">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
</svg>
|
||||
Projects
|
||||
</a>
|
||||
|
||||
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Dark mode toggle and utilities -->
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-2">
|
||||
<button onclick="toggleDarkMode()" class="w-full flex items-center justify-center px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||
<svg id="theme-toggle-dark-icon" class="w-5 h-5 hidden dark:block" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
|
||||
</svg>
|
||||
<svg id="theme-toggle-light-icon" class="w-5 h-5 block dark:hidden" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="ml-3">Toggle theme</span>
|
||||
</button>
|
||||
|
||||
<button onclick="hardReload()" class="w-full flex items-center justify-center px-4 py-3 rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
<span class="ml-3">Clear Cache & Reload</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Backdrop (Mobile Only) -->
|
||||
<div id="backdrop" class="backdrop" onclick="closeMenuFromBackdrop()"></div>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="main-content flex-1 overflow-y-auto">
|
||||
<div class="p-8">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation (Mobile Only) -->
|
||||
<nav class="bottom-nav">
|
||||
<div class="grid grid-cols-4 h-16">
|
||||
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
<span>Menu</span>
|
||||
</button>
|
||||
<button class="bottom-nav-btn" data-href="/" onclick="window.location.href='/'">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
</button>
|
||||
<button class="bottom-nav-btn" data-href="/roster" onclick="window.location.href='/roster'">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
<span>Devices</span>
|
||||
</button>
|
||||
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
// Dark mode toggle
|
||||
function toggleDarkMode() {
|
||||
const html = document.documentElement;
|
||||
if (html.classList.contains('dark')) {
|
||||
html.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
html.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
// Hard reload function - clears all caches and reloads
|
||||
async function hardReload() {
|
||||
try {
|
||||
// Clear service worker caches
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||
console.log('Cleared all service worker caches');
|
||||
}
|
||||
|
||||
// Unregister service workers
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(registrations.map(reg => reg.unregister()));
|
||||
console.log('Unregistered all service workers');
|
||||
}
|
||||
|
||||
// Clear IndexedDB
|
||||
if ('indexedDB' in window) {
|
||||
try {
|
||||
indexedDB.deleteDatabase('sfm-offline-db');
|
||||
console.log('Cleared IndexedDB');
|
||||
} catch (e) {
|
||||
console.log('Could not clear IndexedDB:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Force reload with cache bypass
|
||||
window.location.reload(true);
|
||||
} catch (error) {
|
||||
console.error('Error during hard reload:', error);
|
||||
// Fallback to regular reload
|
||||
window.location.reload(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved theme preference
|
||||
if (localStorage.getItem('theme') === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
} else if (localStorage.getItem('theme') === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
|
||||
// Helper function: Convert timestamp to relative time
|
||||
function timeAgo(dateString) {
|
||||
if (!dateString) return 'Never';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now - date) / 1000);
|
||||
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
const remainingMins = minutes % 60;
|
||||
return remainingMins > 0 ? `${hours}h ${remainingMins}m ago` : `${hours}h ago`;
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) {
|
||||
const remainingHours = hours % 24;
|
||||
return remainingHours > 0 ? `${days}d ${remainingHours}h ago` : `${days}d ago`;
|
||||
}
|
||||
|
||||
const weeks = Math.floor(days / 7);
|
||||
if (weeks < 4) {
|
||||
const remainingDays = days % 7;
|
||||
return remainingDays > 0 ? `${weeks}w ${remainingDays}d ago` : `${weeks}w ago`;
|
||||
}
|
||||
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}mo ago`;
|
||||
}
|
||||
|
||||
// Helper function: Get user's selected timezone
|
||||
function getTimezone() {
|
||||
return localStorage.getItem('timezone') || 'America/New_York';
|
||||
}
|
||||
|
||||
// Helper function: Format timestamp with tooltip (timezone-aware)
|
||||
function formatTimestamp(dateString) {
|
||||
if (!dateString) return '<span class="text-gray-400">Never</span>';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const timezone = getTimezone();
|
||||
|
||||
const fullDate = date.toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
return `<span title="${fullDate}" class="cursor-help">${timeAgo(dateString)}</span>`;
|
||||
}
|
||||
|
||||
// Helper function: Format timestamp as full date/time (no relative time)
|
||||
// Format: "9/10/2020 8:00 AM EST"
|
||||
function formatFullTimestamp(dateString) {
|
||||
if (!dateString) return 'Never';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const timezone = getTimezone();
|
||||
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
}
|
||||
|
||||
// Update all timestamps on page load and periodically
|
||||
function updateAllTimestamps() {
|
||||
document.querySelectorAll('[data-timestamp]').forEach(el => {
|
||||
const timestamp = el.getAttribute('data-timestamp');
|
||||
el.innerHTML = formatTimestamp(timestamp);
|
||||
});
|
||||
}
|
||||
|
||||
// Run on load and every minute
|
||||
updateAllTimestamps();
|
||||
setInterval(updateAllTimestamps, 60000);
|
||||
|
||||
// Copy to clipboard helper
|
||||
function copyToClipboard(text, button) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
// Visual feedback
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
|
||||
button.classList.add('text-green-500');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove('text-green-500');
|
||||
}, 1500);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Offline Database -->
|
||||
<script src="/static/offline-db.js?v=0.4.0"></script>
|
||||
|
||||
<!-- Mobile JavaScript -->
|
||||
<script src="/static/mobile.js?v=0.4.0"></script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
656
app/ui/templates/dashboard.html
Normal file
@@ -0,0 +1,656 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if environment == 'development' %}
|
||||
<div class="mb-4 p-4 bg-yellow-100 dark:bg-yellow-900 border-l-4 border-yellow-500 text-yellow-700 dark:text-yellow-200 rounded">
|
||||
<p class="font-bold">Development Environment</p>
|
||||
<p class="text-sm">You are currently viewing the development version of Seismo Fleet Manager.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Last updated</p>
|
||||
<p id="last-refresh" class="text-sm text-gray-700 dark:text-gray-300 font-mono">--</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard cards with auto-refresh -->
|
||||
<div hx-get="/api/status-snapshot"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="updateDashboard(event)">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
|
||||
<!-- Fleet Summary Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-summary-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-summary-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 card-content" id="fleet-summary-content">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
||||
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
|
||||
<span id="deployed-units" class="text-3xl md:text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
||||
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
<a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
||||
</div>
|
||||
<span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||
</svg>
|
||||
<a href="/sound-level-meters" class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
||||
</div>
|
||||
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
|
||||
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
||||
<div class="flex items-center">
|
||||
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
||||
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">OK</span>
|
||||
</div>
|
||||
<span id="status-ok" class="font-semibold text-green-600 dark:text-green-400">--</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mb-2" title="Units with delayed reports (12-24 hours)">
|
||||
<div class="flex items-center">
|
||||
<span class="w-3 h-3 rounded-full bg-yellow-500 mr-2 flex items-center justify-center">
|
||||
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Pending</span>
|
||||
</div>
|
||||
<span id="status-pending" class="font-semibold text-yellow-600 dark:text-yellow-400">--</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center" title="Units not reporting (> 24 hours)">
|
||||
<div class="flex items-center">
|
||||
<span class="w-3 h-3 rounded-full bg-red-500 mr-2 flex items-center justify-center">
|
||||
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Missing</span>
|
||||
</div>
|
||||
<span id="status-missing" class="font-semibold text-red-600 dark:text-red-400">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Alerts Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-alerts-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recently Called In Units Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-callins-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" id="recent-callins-content">
|
||||
<div id="recent-callins-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
||||
</div>
|
||||
<button id="show-all-callins" class="hidden mt-3 w-full text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
||||
Show all recent call-ins
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Fleet Map -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="fleet-map-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed units</span>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-map-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" id="fleet-map-content">
|
||||
<div id="fleet-map" class="w-full h-64 md:h-96 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Photos Section -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="recent-photos-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-photos-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" id="recent-photos-content">
|
||||
<div id="recentPhotosGallery" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading recent photos...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fleet Status Section with Tabs -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-status-card">
|
||||
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-status')">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center" onclick="event.stopPropagation()">
|
||||
Full Roster
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-status-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-content" id="fleet-status-content">
|
||||
<!-- Tab Bar -->
|
||||
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium tab-button active-tab"
|
||||
hx-get="/dashboard/active"
|
||||
hx-target="#fleet-table"
|
||||
hx-swap="innerHTML">
|
||||
Active
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium tab-button"
|
||||
hx-get="/dashboard/benched"
|
||||
hx-target="#fleet-table"
|
||||
hx-swap="innerHTML">
|
||||
Benched
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content Target -->
|
||||
<div id="fleet-table" class="space-y-2"
|
||||
hx-get="/dashboard/active"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- TAB STYLE -->
|
||||
<style>
|
||||
.tab-button {
|
||||
color: #6b7280; /* gray-500 */
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.tab-button:hover {
|
||||
color: #374151; /* gray-700 */
|
||||
}
|
||||
.active-tab {
|
||||
color: #b84a12 !important; /* seismo orange */
|
||||
border-bottom: 2px solid #b84a12 !important;
|
||||
}
|
||||
|
||||
/* Collapsible cards (mobile only) */
|
||||
@media (max-width: 767px) {
|
||||
.card-content.collapsed {
|
||||
display: none;
|
||||
}
|
||||
.chevron.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<script>
|
||||
// Toggle card collapse/expand (mobile only)
|
||||
function toggleCard(cardName) {
|
||||
// Only work on mobile
|
||||
if (window.innerWidth >= 768) return;
|
||||
|
||||
const content = document.getElementById(`${cardName}-content`);
|
||||
const chevron = document.getElementById(`${cardName}-chevron`);
|
||||
|
||||
if (!content || !chevron) return;
|
||||
|
||||
// Toggle collapsed state
|
||||
const isCollapsed = content.classList.contains('collapsed');
|
||||
|
||||
if (isCollapsed) {
|
||||
content.classList.remove('collapsed');
|
||||
chevron.classList.remove('collapsed');
|
||||
|
||||
// If expanding the fleet map, invalidate size after animation
|
||||
if (cardName === 'fleet-map' && window.fleetMap) {
|
||||
setTimeout(() => {
|
||||
window.fleetMap.invalidateSize();
|
||||
}, 300);
|
||||
}
|
||||
} else {
|
||||
content.classList.add('collapsed');
|
||||
chevron.classList.add('collapsed');
|
||||
}
|
||||
|
||||
// Save state to localStorage
|
||||
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||
cardStates[cardName] = !isCollapsed;
|
||||
localStorage.setItem('dashboardCardStates', JSON.stringify(cardStates));
|
||||
}
|
||||
|
||||
// Restore card states from localStorage on page load
|
||||
function restoreCardStates() {
|
||||
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
|
||||
|
||||
cardNames.forEach(cardName => {
|
||||
const content = document.getElementById(`${cardName}-content`);
|
||||
const chevron = document.getElementById(`${cardName}-chevron`);
|
||||
|
||||
if (!content || !chevron) return;
|
||||
|
||||
// Default to expanded (true) if no saved state
|
||||
const isCollapsed = cardStates[cardName] === false;
|
||||
|
||||
if (isCollapsed) {
|
||||
content.classList.add('collapsed');
|
||||
chevron.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Restore states when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', restoreCardStates);
|
||||
} else {
|
||||
restoreCardStates();
|
||||
}
|
||||
|
||||
function updateDashboard(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.detail.xhr.response);
|
||||
|
||||
// Update "Last updated" timestamp with timezone
|
||||
const now = new Date();
|
||||
const timezone = localStorage.getItem('timezone') || 'America/New_York';
|
||||
document.getElementById('last-refresh').textContent = now.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
// ===== Fleet summary numbers =====
|
||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
||||
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||
|
||||
// ===== Device type counts =====
|
||||
let seismoCount = 0;
|
||||
let slmCount = 0;
|
||||
Object.values(data.units || {}).forEach(unit => {
|
||||
if (unit.retired) return; // Don't count retired units
|
||||
const deviceType = unit.device_type || 'seismograph';
|
||||
if (deviceType === 'seismograph') {
|
||||
seismoCount++;
|
||||
} else if (deviceType === 'sound_level_meter') {
|
||||
slmCount++;
|
||||
}
|
||||
});
|
||||
document.getElementById('seismo-count').textContent = seismoCount;
|
||||
document.getElementById('slm-count').textContent = slmCount;
|
||||
|
||||
// ===== Alerts =====
|
||||
const alertsList = document.getElementById('alerts-list');
|
||||
// Only show alerts for deployed units that are MISSING (not pending)
|
||||
const missingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Missing');
|
||||
|
||||
if (!missingUnits.length) {
|
||||
alertsList.innerHTML =
|
||||
'<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
|
||||
} else {
|
||||
let alertsHtml = '';
|
||||
|
||||
missingUnits.forEach(([id, unit]) => {
|
||||
alertsHtml += `
|
||||
<div class="flex items-start space-x-2 text-sm">
|
||||
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
|
||||
<div>
|
||||
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
|
||||
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
alertsList.innerHTML = alertsHtml;
|
||||
}
|
||||
|
||||
// ===== Update Fleet Map =====
|
||||
updateFleetMap(data);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Dashboard update error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tab switching
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tabButtons = document.querySelectorAll('.tab-button');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
// Remove active-tab class from all buttons
|
||||
tabButtons.forEach(btn => btn.classList.remove('active-tab'));
|
||||
// Add active-tab class to clicked button
|
||||
this.classList.add('active-tab');
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize fleet map
|
||||
initFleetMap();
|
||||
});
|
||||
|
||||
let fleetMap = null;
|
||||
let fleetMarkers = [];
|
||||
let fleetMapInitialized = false;
|
||||
|
||||
// Make fleetMap accessible globally for toggleCard function
|
||||
window.fleetMap = null;
|
||||
|
||||
function initFleetMap() {
|
||||
// Initialize the map centered on the US (can adjust based on your deployment area)
|
||||
fleetMap = L.map('fleet-map').setView([39.8283, -98.5795], 4);
|
||||
window.fleetMap = fleetMap;
|
||||
|
||||
// Add OpenStreetMap tiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 18
|
||||
}).addTo(fleetMap);
|
||||
|
||||
// Force map to recalculate size after a brief delay to ensure container is fully rendered
|
||||
setTimeout(() => {
|
||||
fleetMap.invalidateSize();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function updateFleetMap(data) {
|
||||
if (!fleetMap) return;
|
||||
|
||||
// Clear existing markers
|
||||
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
||||
fleetMarkers = [];
|
||||
|
||||
// Get deployed units with coordinates data
|
||||
const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.coordinates);
|
||||
|
||||
if (deployedUnits.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = [];
|
||||
|
||||
deployedUnits.forEach(([id, unit]) => {
|
||||
const coords = parseLocation(unit.coordinates);
|
||||
if (coords) {
|
||||
const [lat, lon] = coords;
|
||||
|
||||
// Create marker with custom color based on status
|
||||
const markerColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'orange' : 'red';
|
||||
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
radius: 8,
|
||||
fillColor: markerColor,
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8
|
||||
}).addTo(fleetMap);
|
||||
|
||||
// Add popup with unit info
|
||||
marker.bindPopup(`
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg">${id}</h3>
|
||||
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
||||
<p class="text-sm">Type: ${unit.device_type}</p>
|
||||
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
|
||||
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details →</a>
|
||||
</div>
|
||||
`);
|
||||
|
||||
fleetMarkers.push(marker);
|
||||
bounds.push([lat, lon]);
|
||||
}
|
||||
});
|
||||
|
||||
// Fit map to show all markers
|
||||
if (bounds.length > 0) {
|
||||
// Use different padding for mobile vs desktop
|
||||
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
|
||||
fleetMap.fitBounds(bounds, { padding: padding });
|
||||
fleetMapInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function parseLocation(location) {
|
||||
if (!location) return null;
|
||||
|
||||
// Try to parse as "lat,lon" format
|
||||
const parts = location.split(',').map(s => s.trim());
|
||||
if (parts.length === 2) {
|
||||
const lat = parseFloat(parts[0]);
|
||||
const lon = parseFloat(parts[1]);
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
return [lat, lon];
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add geocoding support for address strings
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load and display recent photos
|
||||
async function loadRecentPhotos() {
|
||||
try {
|
||||
const response = await fetch('/api/recent-photos?limit=12');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load recent photos');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const gallery = document.getElementById('recentPhotosGallery');
|
||||
|
||||
if (data.photos && data.photos.length > 0) {
|
||||
gallery.innerHTML = '';
|
||||
data.photos.forEach(photo => {
|
||||
const photoDiv = document.createElement('div');
|
||||
photoDiv.className = 'relative group';
|
||||
photoDiv.innerHTML = `
|
||||
<a href="/unit/${photo.unit_id}" class="block">
|
||||
<img src="${photo.path}" alt="${photo.unit_id}"
|
||||
class="w-full h-32 object-cover rounded-lg shadow hover:shadow-lg transition-shadow">
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 rounded-b-lg">
|
||||
<p class="text-white text-xs font-semibold">${photo.unit_id}</p>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
gallery.appendChild(photoDiv);
|
||||
});
|
||||
} else {
|
||||
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos uploaded yet. Upload photos from unit detail pages.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading recent photos:', error);
|
||||
document.getElementById('recentPhotosGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load recent photos</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Load recent photos on page load and refresh every 30 seconds
|
||||
loadRecentPhotos();
|
||||
setInterval(loadRecentPhotos, 30000);
|
||||
|
||||
// Load and display recent call-ins
|
||||
let showingAllCallins = false;
|
||||
const DEFAULT_CALLINS_DISPLAY = 5;
|
||||
|
||||
async function loadRecentCallins() {
|
||||
try {
|
||||
const response = await fetch('/api/recent-callins?hours=6');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load recent call-ins');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const callinsList = document.getElementById('recent-callins-list');
|
||||
const showAllButton = document.getElementById('show-all-callins');
|
||||
|
||||
if (data.call_ins && data.call_ins.length > 0) {
|
||||
// Determine how many to show
|
||||
const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length);
|
||||
const callinsToDisplay = data.call_ins.slice(0, displayCount);
|
||||
|
||||
// Build HTML for call-ins list
|
||||
let html = '';
|
||||
callinsToDisplay.forEach(callin => {
|
||||
// Status color
|
||||
const statusColor = callin.status === 'OK' ? 'green' : callin.status === 'Pending' ? 'yellow' : 'red';
|
||||
const statusClass = callin.status === 'OK' ? 'bg-green-500' : callin.status === 'Pending' ? 'bg-yellow-500' : 'bg-red-500';
|
||||
|
||||
// Build location/note line
|
||||
let subtitle = '';
|
||||
if (callin.location) {
|
||||
subtitle = callin.location;
|
||||
} else if (callin.note) {
|
||||
subtitle = callin.note;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-2 h-2 rounded-full ${statusClass}"></span>
|
||||
<div>
|
||||
<a href="/unit/${callin.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
|
||||
${callin.unit_id}
|
||||
</a>
|
||||
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400">${subtitle}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">${callin.time_ago}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
callinsList.innerHTML = html;
|
||||
|
||||
// Show/hide the "Show all" button
|
||||
if (data.call_ins.length > DEFAULT_CALLINS_DISPLAY) {
|
||||
showAllButton.classList.remove('hidden');
|
||||
showAllButton.textContent = showingAllCallins
|
||||
? `Show fewer (${DEFAULT_CALLINS_DISPLAY})`
|
||||
: `Show all (${data.call_ins.length})`;
|
||||
} else {
|
||||
showAllButton.classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No units have called in within the past 6 hours</p>';
|
||||
showAllButton.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading recent call-ins:', error);
|
||||
document.getElementById('recent-callins-list').innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle show all/show fewer
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const showAllButton = document.getElementById('show-all-callins');
|
||||
showAllButton.addEventListener('click', function() {
|
||||
showingAllCallins = !showingAllCallins;
|
||||
loadRecentCallins();
|
||||
});
|
||||
});
|
||||
|
||||
// Load recent call-ins on page load and refresh every 30 seconds
|
||||
loadRecentCallins();
|
||||
setInterval(loadRecentCallins, 30000);
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
56
app/ui/templates/partials/active_table.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% if units %}
|
||||
<div class="space-y-2">
|
||||
{% for unit_id, unit in units.items() %}
|
||||
<div class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="flex items-center space-x-3 flex-1">
|
||||
<!-- Status Indicator -->
|
||||
<div class="flex-shrink-0">
|
||||
{% if unit.status == 'OK' %}
|
||||
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||
{% elif unit.status == 'Pending' %}
|
||||
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||
{% else %}
|
||||
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Unit Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/unit/{{ unit_id }}" class="font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||
{{ unit_id }}
|
||||
</a>
|
||||
{% if unit.device_type == 'modem' %}
|
||||
<span class="px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs">
|
||||
Modem
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs">
|
||||
Seismograph
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if unit.note %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ unit.note }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Age -->
|
||||
<div class="text-right flex-shrink-0">
|
||||
<span class="text-sm {% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400{% else %}text-gray-500 dark:text-gray-400{% endif %}">
|
||||
{{ unit.age }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No active units</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a new unit to the fleet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
56
app/ui/templates/partials/benched_table.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% if units %}
|
||||
<div class="space-y-2">
|
||||
{% for unit_id, unit in units.items() %}
|
||||
<div class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="flex items-center space-x-3 flex-1">
|
||||
<!-- Status Indicator (grayed out for benched) -->
|
||||
<div class="flex-shrink-0">
|
||||
<span class="w-4 h-4 rounded-full bg-gray-400" title="Benched"></span>
|
||||
</div>
|
||||
|
||||
<!-- Unit Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/unit/{{ unit_id }}" class="font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||
{{ unit_id }}
|
||||
</a>
|
||||
{% if unit.device_type == 'modem' %}
|
||||
<span class="px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs">
|
||||
Modem
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs">
|
||||
Seismograph
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if unit.note %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ unit.note }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Last Activity (if any) -->
|
||||
<div class="text-right flex-shrink-0">
|
||||
{% if unit.age != 'N/A' %}
|
||||
<span class="text-sm text-gray-400 dark:text-gray-500">
|
||||
Last seen: {{ unit.age }} ago
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-400 dark:text-gray-500">
|
||||
No data
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No benched units</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Units awaiting deployment will appear here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
23
app/ui/templates/partials/dashboard_active.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% for id, unit in units.items() %}
|
||||
<a href="/unit/{{ id }}"
|
||||
class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700">
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-3 h-3 rounded-full
|
||||
{% if unit.status == 'OK' %} bg-green-500
|
||||
{% elif unit.status == 'Pending' %} bg-yellow-500
|
||||
{% else %} bg-red-500 {% endif %}">
|
||||
</span>
|
||||
|
||||
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
|
||||
|
||||
<span class="font-medium">{{ id }}</span>
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ unit.age }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if units|length == 0 %}
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">No active units</p>
|
||||
{% endif %}
|
||||
23
app/ui/templates/partials/dashboard_benched.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% for id, unit in units.items() %}
|
||||
<a href="/unit/{{ id }}"
|
||||
class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700">
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-3 h-3 rounded-full
|
||||
{% if unit.status == 'OK' %} bg-green-500
|
||||
{% elif unit.status == 'Pending' %} bg-yellow-500
|
||||
{% else %} bg-red-500 {% endif %}">
|
||||
</span>
|
||||
|
||||
<!-- No deployed dot for benched units -->
|
||||
|
||||
<span class="font-medium">{{ id }}</span>
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ unit.age }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if units|length == 0 %}
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">No benched units</p>
|
||||
{% endif %}
|
||||
449
app/ui/templates/partials/devices_table.html
Normal file
@@ -0,0 +1,449 @@
|
||||
<!-- Desktop Table View -->
|
||||
<div class="hidden md:block rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||
<table id="roster-table" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('status')">
|
||||
<div class="flex items-center gap-1">
|
||||
Status
|
||||
<span class="sort-indicator" data-column="status"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('id')">
|
||||
<div class="flex items-center gap-1">
|
||||
Unit ID
|
||||
<span class="sort-indicator" data-column="id"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('type')">
|
||||
<div class="flex items-center gap-1">
|
||||
Type
|
||||
<span class="sort-indicator" data-column="type"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Details
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('last_seen')">
|
||||
<div class="flex items-center gap-1">
|
||||
Last Seen
|
||||
<span class="sort-indicator" data-column="last_seen"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('age')">
|
||||
<div class="flex items-center gap-1">
|
||||
Age
|
||||
<span class="sort-indicator" data-column="age"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('note')">
|
||||
<div class="flex items-center gap-1">
|
||||
Note
|
||||
<span class="sort-indicator" data-column="note"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="roster-tbody" class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for unit in units %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
data-device-type="{{ unit.device_type }}"
|
||||
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||
data-health="{{ unit.status }}"
|
||||
data-id="{{ unit.id }}"
|
||||
data-type="{{ unit.device_type }}"
|
||||
data-last-seen="{{ unit.last_seen }}"
|
||||
data-age="{{ unit.age }}"
|
||||
data-note="{{ unit.note if unit.note else '' }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center space-x-2">
|
||||
{% if unit.status == 'OK' %}
|
||||
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||
{% elif unit.status == 'Pending' %}
|
||||
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
||||
{% else %}
|
||||
<span class="w-3 h-3 rounded-full bg-red-500" title="Missing"></span>
|
||||
{% endif %}
|
||||
|
||||
{% if unit.deployed %}
|
||||
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if unit.device_type == 'modem' %}
|
||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||
Modem
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||
Seismograph
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
{% if unit.device_type == 'modem' %}
|
||||
{% if unit.ip_address %}
|
||||
<div><span class="font-mono">{{ unit.ip_address }}</span></div>
|
||||
{% endif %}
|
||||
{% if unit.phone_number %}
|
||||
<div>{{ unit.phone_number }}</div>
|
||||
{% endif %}
|
||||
{% if unit.hardware_model %}
|
||||
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if unit.next_calibration_due %}
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
|
||||
<span class="font-medium">{{ unit.next_calibration_due }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-500">Modem:</span>
|
||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-seismo-orange hover:underline font-medium">
|
||||
{{ unit.deployed_with_modem_id }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm
|
||||
{% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold
|
||||
{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400
|
||||
{% else %}text-gray-500 dark:text-gray-400
|
||||
{% endif %}">
|
||||
{{ unit.age }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs" title="{{ unit.note }}">
|
||||
{{ unit.note if unit.note else '-' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button onclick="editUnit('{{ unit.id }}')"
|
||||
class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{% if unit.deployed %}
|
||||
<button onclick="toggleDeployed('{{ unit.id }}', false)"
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-1" title="Mark as Benched">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{% else %}
|
||||
<button onclick="toggleDeployed('{{ unit.id }}', true)"
|
||||
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Mark as Deployed">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button onclick="moveToIgnore('{{ unit.id }}')"
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-1" title="Move to Ignore List">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="deleteUnit('{{ unit.id }}')"
|
||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Last updated indicator -->
|
||||
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||
Last updated: <span id="last-updated">{{ timestamp }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Card View -->
|
||||
<div class="md:hidden space-y-3">
|
||||
{% for unit in units %}
|
||||
<div class="unit-card device-card"
|
||||
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||
data-device-type="{{ unit.device_type }}"
|
||||
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||
data-health="{{ unit.status }}"
|
||||
data-unit-id="{{ unit.id }}"
|
||||
data-age="{{ unit.age }}">
|
||||
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{% if unit.status == 'OK' %}
|
||||
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||
{% elif unit.status == 'Pending' %}
|
||||
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||
{% elif unit.status == 'Missing' %}
|
||||
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
|
||||
{% else %}
|
||||
<span class="w-4 h-4 rounded-full bg-gray-400" title="No Data"></span>
|
||||
{% endif %}
|
||||
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
||||
</div>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||
{% endif %}">
|
||||
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Type Badge -->
|
||||
<div class="mb-2">
|
||||
{% if unit.device_type == 'modem' %}
|
||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||
Modem
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||
Seismograph
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
{% if unit.address %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
📍 {{ unit.address }}
|
||||
</div>
|
||||
{% elif unit.coordinates %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
📍 {{ unit.coordinates }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Project ID -->
|
||||
{% if unit.project_id %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
🏗️ {{ unit.project_id }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Last Seen -->
|
||||
<div class="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
||||
🕐 {{ unit.age }}
|
||||
</div>
|
||||
|
||||
<!-- Deployed/Benched Indicator -->
|
||||
<div class="mt-2">
|
||||
{% if unit.deployed %}
|
||||
<span class="text-xs text-blue-600 dark:text-blue-400">
|
||||
⚡ Deployed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||
📦 Benched
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tap Hint -->
|
||||
<div class="text-xs text-gray-400 mt-2 text-center border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||
Tap for details
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Mobile Last Updated -->
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||
Last updated: <span id="last-updated-mobile">{{ timestamp }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unit Detail Modal -->
|
||||
<div id="unitModal" class="unit-modal">
|
||||
<!-- Backdrop -->
|
||||
<div class="unit-modal-backdrop" onclick="closeUnitModal(event)"></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="unit-modal-content">
|
||||
<!-- Handle Bar (Mobile Only) -->
|
||||
<div class="modal-handle"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 id="modalUnitId" class="text-xl font-bold text-gray-900 dark:text-white"></h3>
|
||||
<button onclick="closeUnitModal(event)" data-close-modal class="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div id="modalContent" class="p-6">
|
||||
<!-- Content will be populated by JavaScript -->
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<button id="modalEditBtn" class="w-full h-12 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
||||
Edit Unit
|
||||
</button>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button id="modalDeployBtn" class="h-12 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
Deploy/Bench
|
||||
</button>
|
||||
<button id="modalDeleteBtn" class="h-12 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sort-indicator::after {
|
||||
content: '⇅';
|
||||
opacity: 0.3;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sort-indicator.asc::after {
|
||||
content: '↑';
|
||||
opacity: 1;
|
||||
}
|
||||
.sort-indicator.desc::after {
|
||||
content: '↓';
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Update timestamp
|
||||
const timestampElement = document.getElementById('last-updated');
|
||||
if (timestampElement) {
|
||||
timestampElement.textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
const timestampMobileElement = document.getElementById('last-updated-mobile');
|
||||
if (timestampMobileElement) {
|
||||
timestampMobileElement.textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
// Keep a lightweight status map around for the mobile modal
|
||||
const rosterUnits = {{ units | tojson }};
|
||||
window.rosterStatusMap = rosterUnits.reduce((acc, unit) => {
|
||||
acc[unit.id] = {
|
||||
status: unit.status || 'Unknown',
|
||||
age: unit.age || 'N/A',
|
||||
last: unit.last_seen || 'Never'
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Sorting state
|
||||
let currentSort = { column: null, direction: 'asc' };
|
||||
|
||||
function sortTable(column) {
|
||||
const tbody = document.getElementById('roster-tbody');
|
||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||
|
||||
// Determine sort direction
|
||||
if (currentSort.column === column) {
|
||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSort.column = column;
|
||||
currentSort.direction = 'asc';
|
||||
}
|
||||
|
||||
// Sort rows
|
||||
rows.sort((a, b) => {
|
||||
let aVal = a.getAttribute(`data-${column}`) || '';
|
||||
let bVal = b.getAttribute(`data-${column}`) || '';
|
||||
|
||||
// Special handling for different column types
|
||||
if (column === 'age') {
|
||||
// Parse age strings like "2h 15m" or "45m" or "3d 5h"
|
||||
aVal = parseAge(aVal);
|
||||
bVal = parseAge(bVal);
|
||||
} else if (column === 'status') {
|
||||
// Sort by status priority: Missing > Pending > OK
|
||||
const statusOrder = { 'Missing': 0, 'Pending': 1, 'OK': 2, '': 3 };
|
||||
aVal = statusOrder[aVal] !== undefined ? statusOrder[aVal] : 999;
|
||||
bVal = statusOrder[bVal] !== undefined ? statusOrder[bVal] : 999;
|
||||
} else if (column === 'last_seen') {
|
||||
// Sort by date
|
||||
aVal = new Date(aVal).getTime() || 0;
|
||||
bVal = new Date(bVal).getTime() || 0;
|
||||
} else {
|
||||
// String comparison (case-insensitive)
|
||||
aVal = aVal.toLowerCase();
|
||||
bVal = bVal.toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Re-append rows in sorted order
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
|
||||
// Update sort indicators
|
||||
updateSortIndicators();
|
||||
}
|
||||
|
||||
function parseAge(ageStr) {
|
||||
// Parse age strings like "2h 15m", "45m", "3d 5h", "2w 3d"
|
||||
if (!ageStr) return 0;
|
||||
|
||||
let totalMinutes = 0;
|
||||
const weeks = ageStr.match(/(\d+)w/);
|
||||
const days = ageStr.match(/(\d+)d/);
|
||||
const hours = ageStr.match(/(\d+)h/);
|
||||
const minutes = ageStr.match(/(\d+)m/);
|
||||
|
||||
if (weeks) totalMinutes += parseInt(weeks[1]) * 7 * 24 * 60;
|
||||
if (days) totalMinutes += parseInt(days[1]) * 24 * 60;
|
||||
if (hours) totalMinutes += parseInt(hours[1]) * 60;
|
||||
if (minutes) totalMinutes += parseInt(minutes[1]);
|
||||
|
||||
return totalMinutes;
|
||||
}
|
||||
|
||||
function updateSortIndicators() {
|
||||
// Clear all indicators
|
||||
document.querySelectorAll('.sort-indicator').forEach(indicator => {
|
||||
indicator.className = 'sort-indicator';
|
||||
});
|
||||
|
||||
// Set current indicator
|
||||
if (currentSort.column) {
|
||||
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
||||
if (indicator) {
|
||||
indicator.className = `sort-indicator ${currentSort.direction}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
69
app/ui/templates/partials/ignored_table.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Unit ID
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Reason
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Ignored At
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% if ignored_units %}
|
||||
{% for unit in ignored_units %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-gray-400" title="Ignored"></span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ unit.id }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ unit.reason }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ unit.ignored_at }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button onclick="unignoreUnit('{{ unit.id }}')"
|
||||
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Un-ignore Unit">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="deleteIgnoredUnit('{{ unit.id }}')"
|
||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No ignored units
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Last updated indicator -->
|
||||
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||
Last updated: {{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
77
app/ui/templates/partials/retired_table.html
Normal file
@@ -0,0 +1,77 @@
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Unit ID
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Note
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% if units %}
|
||||
{% for unit in units %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-gray-500" title="Retired"></span>
|
||||
<a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if unit.device_type == 'modem' %}
|
||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||
Modem
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||
Seismograph
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ unit.note }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button onclick="editUnit('{{ unit.id }}')"
|
||||
class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="deleteUnit('{{ unit.id }}')"
|
||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No retired units
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Last updated indicator -->
|
||||
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||
Last updated: {{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
445
app/ui/templates/partials/roster_table.html
Normal file
@@ -0,0 +1,445 @@
|
||||
<!-- Desktop Table View -->
|
||||
<div class="hidden md:block rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||
<table id="roster-table" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('status')">
|
||||
<div class="flex items-center gap-1">
|
||||
Status
|
||||
<span class="sort-indicator" data-column="status"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('id')">
|
||||
<div class="flex items-center gap-1">
|
||||
Unit ID
|
||||
<span class="sort-indicator" data-column="id"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('type')">
|
||||
<div class="flex items-center gap-1">
|
||||
Type
|
||||
<span class="sort-indicator" data-column="type"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Details
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('last_seen')">
|
||||
<div class="flex items-center gap-1">
|
||||
Last Seen
|
||||
<span class="sort-indicator" data-column="last_seen"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('age')">
|
||||
<div class="flex items-center gap-1">
|
||||
Age
|
||||
<span class="sort-indicator" data-column="age"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('note')">
|
||||
<div class="flex items-center gap-1">
|
||||
Note
|
||||
<span class="sort-indicator" data-column="note"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="roster-tbody" class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for unit in units %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
data-status="{{ unit.status }}"
|
||||
data-id="{{ unit.id }}"
|
||||
data-type="{{ unit.device_type }}"
|
||||
data-last-seen="{{ unit.last_seen }}"
|
||||
data-age="{{ unit.age }}"
|
||||
data-note="{{ unit.note if unit.note else '' }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center space-x-2">
|
||||
{% if unit.status == 'OK' %}
|
||||
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||
{% elif unit.status == 'Pending' %}
|
||||
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
||||
{% else %}
|
||||
<span class="w-3 h-3 rounded-full bg-red-500" title="Missing"></span>
|
||||
{% endif %}
|
||||
|
||||
{% if unit.deployed %}
|
||||
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if unit.device_type == 'modem' %}
|
||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||
Modem
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||
Seismograph
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
{% if unit.device_type == 'modem' %}
|
||||
{% if unit.ip_address %}
|
||||
<div><span class="font-mono">{{ unit.ip_address }}</span></div>
|
||||
{% endif %}
|
||||
{% if unit.phone_number %}
|
||||
<div>{{ unit.phone_number }}</div>
|
||||
{% endif %}
|
||||
{% if unit.hardware_model %}
|
||||
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if unit.next_calibration_due %}
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
|
||||
<span class="font-medium">{{ unit.next_calibration_due }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-500">Modem:</span>
|
||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-seismo-orange hover:underline font-medium">
|
||||
{{ unit.deployed_with_modem_id }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm
|
||||
{% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold
|
||||
{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400
|
||||
{% else %}text-gray-500 dark:text-gray-400
|
||||
{% endif %}">
|
||||
{{ unit.age }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs" title="{{ unit.note }}">
|
||||
{{ unit.note if unit.note else '-' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button onclick="editUnit('{{ unit.id }}')"
|
||||
class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{% if unit.deployed %}
|
||||
<button onclick="toggleDeployed('{{ unit.id }}', false)"
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-1" title="Mark as Benched">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{% else %}
|
||||
<button onclick="toggleDeployed('{{ unit.id }}', true)"
|
||||
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Mark as Deployed">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button onclick="moveToIgnore('{{ unit.id }}')"
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-1" title="Move to Ignore List">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="deleteUnit('{{ unit.id }}')"
|
||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Last updated indicator -->
|
||||
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||
Last updated: <span id="last-updated">{{ timestamp }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Card View -->
|
||||
<div class="md:hidden space-y-3">
|
||||
{% for unit in units %}
|
||||
<div class="unit-card"
|
||||
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||
data-unit-id="{{ unit.id }}"
|
||||
data-status="{{ unit.status }}"
|
||||
data-age="{{ unit.age }}">
|
||||
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{% if unit.status == 'OK' %}
|
||||
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||
{% elif unit.status == 'Pending' %}
|
||||
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||
{% elif unit.status == 'Missing' %}
|
||||
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
|
||||
{% else %}
|
||||
<span class="w-4 h-4 rounded-full bg-gray-400" title="No Data"></span>
|
||||
{% endif %}
|
||||
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
||||
</div>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||
{% endif %}">
|
||||
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Type Badge -->
|
||||
<div class="mb-2">
|
||||
{% if unit.device_type == 'modem' %}
|
||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||
Modem
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||
Seismograph
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
{% if unit.address %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
📍 {{ unit.address }}
|
||||
</div>
|
||||
{% elif unit.coordinates %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
📍 {{ unit.coordinates }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Project ID -->
|
||||
{% if unit.project_id %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
🏗️ {{ unit.project_id }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Last Seen -->
|
||||
<div class="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
||||
🕐 {{ unit.age }}
|
||||
</div>
|
||||
|
||||
<!-- Deployed/Benched Indicator -->
|
||||
<div class="mt-2">
|
||||
{% if unit.deployed %}
|
||||
<span class="text-xs text-blue-600 dark:text-blue-400">
|
||||
⚡ Deployed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||
📦 Benched
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tap Hint -->
|
||||
<div class="text-xs text-gray-400 mt-2 text-center border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||
Tap for details
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Mobile Last Updated -->
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||
Last updated: <span id="last-updated-mobile">{{ timestamp }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unit Detail Modal -->
|
||||
<div id="unitModal" class="unit-modal">
|
||||
<!-- Backdrop -->
|
||||
<div class="unit-modal-backdrop" onclick="closeUnitModal(event)"></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="unit-modal-content">
|
||||
<!-- Handle Bar (Mobile Only) -->
|
||||
<div class="modal-handle"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 id="modalUnitId" class="text-xl font-bold text-gray-900 dark:text-white"></h3>
|
||||
<button onclick="closeUnitModal(event)" data-close-modal class="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div id="modalContent" class="p-6">
|
||||
<!-- Content will be populated by JavaScript -->
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<button id="modalEditBtn" class="w-full h-12 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
||||
Edit Unit
|
||||
</button>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button id="modalDeployBtn" class="h-12 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
Deploy/Bench
|
||||
</button>
|
||||
<button id="modalDeleteBtn" class="h-12 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sort-indicator::after {
|
||||
content: '⇅';
|
||||
opacity: 0.3;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sort-indicator.asc::after {
|
||||
content: '↑';
|
||||
opacity: 1;
|
||||
}
|
||||
.sort-indicator.desc::after {
|
||||
content: '↓';
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Update timestamp
|
||||
const timestampElement = document.getElementById('last-updated');
|
||||
if (timestampElement) {
|
||||
timestampElement.textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
const timestampMobileElement = document.getElementById('last-updated-mobile');
|
||||
if (timestampMobileElement) {
|
||||
timestampMobileElement.textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
// Keep a lightweight status map around for the mobile modal
|
||||
const rosterUnits = {{ units | tojson }};
|
||||
window.rosterStatusMap = rosterUnits.reduce((acc, unit) => {
|
||||
acc[unit.id] = {
|
||||
status: unit.status || 'Unknown',
|
||||
age: unit.age || 'N/A',
|
||||
last: unit.last_seen || 'Never'
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Sorting state
|
||||
let currentSort = { column: null, direction: 'asc' };
|
||||
|
||||
function sortTable(column) {
|
||||
const tbody = document.getElementById('roster-tbody');
|
||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||
|
||||
// Determine sort direction
|
||||
if (currentSort.column === column) {
|
||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSort.column = column;
|
||||
currentSort.direction = 'asc';
|
||||
}
|
||||
|
||||
// Sort rows
|
||||
rows.sort((a, b) => {
|
||||
let aVal = a.getAttribute(`data-${column}`) || '';
|
||||
let bVal = b.getAttribute(`data-${column}`) || '';
|
||||
|
||||
// Special handling for different column types
|
||||
if (column === 'age') {
|
||||
// Parse age strings like "2h 15m" or "45m" or "3d 5h"
|
||||
aVal = parseAge(aVal);
|
||||
bVal = parseAge(bVal);
|
||||
} else if (column === 'status') {
|
||||
// Sort by status priority: Missing > Pending > OK
|
||||
const statusOrder = { 'Missing': 0, 'Pending': 1, 'OK': 2, '': 3 };
|
||||
aVal = statusOrder[aVal] !== undefined ? statusOrder[aVal] : 999;
|
||||
bVal = statusOrder[bVal] !== undefined ? statusOrder[bVal] : 999;
|
||||
} else if (column === 'last_seen') {
|
||||
// Sort by date
|
||||
aVal = new Date(aVal).getTime() || 0;
|
||||
bVal = new Date(bVal).getTime() || 0;
|
||||
} else {
|
||||
// String comparison (case-insensitive)
|
||||
aVal = aVal.toLowerCase();
|
||||
bVal = bVal.toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Re-append rows in sorted order
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
|
||||
// Update sort indicators
|
||||
updateSortIndicators();
|
||||
}
|
||||
|
||||
function parseAge(ageStr) {
|
||||
// Parse age strings like "2h 15m", "45m", "3d 5h", "2w 3d"
|
||||
if (!ageStr) return 0;
|
||||
|
||||
let totalMinutes = 0;
|
||||
const weeks = ageStr.match(/(\d+)w/);
|
||||
const days = ageStr.match(/(\d+)d/);
|
||||
const hours = ageStr.match(/(\d+)h/);
|
||||
const minutes = ageStr.match(/(\d+)m/);
|
||||
|
||||
if (weeks) totalMinutes += parseInt(weeks[1]) * 7 * 24 * 60;
|
||||
if (days) totalMinutes += parseInt(days[1]) * 24 * 60;
|
||||
if (hours) totalMinutes += parseInt(hours[1]) * 60;
|
||||
if (minutes) totalMinutes += parseInt(minutes[1]);
|
||||
|
||||
return totalMinutes;
|
||||
}
|
||||
|
||||
function updateSortIndicators() {
|
||||
// Clear all indicators
|
||||
document.querySelectorAll('.sort-indicator').forEach(indicator => {
|
||||
indicator.className = 'sort-indicator';
|
||||
});
|
||||
|
||||
// Set current indicator
|
||||
if (currentSort.column) {
|
||||
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
||||
if (indicator) {
|
||||
indicator.className = `sort-indicator ${currentSort.direction}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
56
app/ui/templates/partials/seismo_stats.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- Total Seismographs -->
|
||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Total Seismographs</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ total }}</p>
|
||||
</div>
|
||||
<svg class="w-12 h-12 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deployed -->
|
||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Deployed</p>
|
||||
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">{{ deployed }}</p>
|
||||
</div>
|
||||
<svg class="w-12 h-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benched -->
|
||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
||||
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
|
||||
</div>
|
||||
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- With Modem -->
|
||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">With Modem</p>
|
||||
<p class="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-2">{{ with_modem }}<span class="text-base text-gray-500">/ {{ deployed }}</span></p>
|
||||
{% if without_modem > 0 %}
|
||||
<p class="text-xs text-orange-600 dark:text-orange-400 mt-1">{{ without_modem }} without modem</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<svg class="w-12 h-12 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
97
app/ui/templates/partials/seismo_unit_list.html
Normal file
@@ -0,0 +1,97 @@
|
||||
{% if units %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Unit ID</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Modem</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Notes</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for unit in units %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{% if unit.deployed %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Deployed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Benched
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.deployed_with_modem_id }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.address %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||
{% elif unit.coordinates %}
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
||||
{% if unit.note %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View Details →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if search %}
|
||||
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Found {{ units|length }} seismograph(s) matching "{{ search }}"
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No seismographs found</h3>
|
||||
{% if search %}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No seismographs match "{{ search }}"</p>
|
||||
<button onclick="document.getElementById('seismo-search').value = ''; htmx.trigger('#seismo-search', 'keyup');"
|
||||
class="mt-3 text-blue-600 dark:text-blue-400 hover:underline">
|
||||
Clear search
|
||||
</button>
|
||||
{% else %}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a seismograph unit from the roster page.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
288
app/ui/templates/partials/slm_config_form.html
Normal file
@@ -0,0 +1,288 @@
|
||||
<form id="slm-config-form"
|
||||
hx-post="/api/slm-dashboard/config/{{ unit.id }}"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="handleConfigSave(event)">
|
||||
|
||||
<div class="mb-6">
|
||||
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Unit: {{ unit.id }}</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Configure measurement parameters for this sound level meter</p>
|
||||
</div>
|
||||
|
||||
<!-- Model & Serial -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
|
||||
<select name="slm_model" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select model...</option>
|
||||
<option value="NL-43" {% if unit.slm_model == 'NL-43' %}selected{% endif %}>NL-43</option>
|
||||
<option value="NL-53" {% if unit.slm_model == 'NL-53' %}selected{% endif %}>NL-53</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
||||
<input type="text" name="slm_serial_number" value="{{ unit.slm_serial_number or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="e.g., SN123456">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frequency & Time Weighting -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
||||
<select name="slm_frequency_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select...</option>
|
||||
<option value="A" {% if unit.slm_frequency_weighting == 'A' %}selected{% endif %}>A-weighting</option>
|
||||
<option value="C" {% if unit.slm_frequency_weighting == 'C' %}selected{% endif %}>C-weighting</option>
|
||||
<option value="Z" {% if unit.slm_frequency_weighting == 'Z' %}selected{% endif %}>Z-weighting (Linear)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
||||
<select name="slm_time_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select...</option>
|
||||
<option value="Fast" {% if unit.slm_time_weighting == 'Fast' %}selected{% endif %}>Fast (125ms)</option>
|
||||
<option value="Slow" {% if unit.slm_time_weighting == 'Slow' %}selected{% endif %}>Slow (1s)</option>
|
||||
<option value="Impulse" {% if unit.slm_time_weighting == 'Impulse' %}selected{% endif %}>Impulse</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Measurement Range -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
||||
<select name="slm_measurement_range" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select range...</option>
|
||||
<option value="30-130" {% if unit.slm_measurement_range == '30-130' %}selected{% endif %}>30-130 dB</option>
|
||||
<option value="40-140" {% if unit.slm_measurement_range == '40-140' %}selected{% endif %}>40-140 dB</option>
|
||||
<option value="50-140" {% if unit.slm_measurement_range == '50-140' %}selected{% endif %}>50-140 dB</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Network Configuration -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-6 mb-4">
|
||||
<h5 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Network Configuration</h5>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned Modem</label>
|
||||
<div class="flex gap-2">
|
||||
<select name="deployed_with_modem_id" id="config-modem-select" class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">No modem (direct connection)</option>
|
||||
<!-- Options loaded via JavaScript -->
|
||||
</select>
|
||||
<button type="button" id="test-modem-btn" onclick="testModemConnection()"
|
||||
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors {% if not unit.deployed_with_modem_id %}opacity-50 cursor-not-allowed{% endif %}"
|
||||
{% if not unit.deployed_with_modem_id %}disabled{% endif %}
|
||||
title="Ping modem to test connectivity">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select a modem for network access, or leave blank for direct IP connection</p>
|
||||
</div>
|
||||
|
||||
<!-- Port Configuration (always visible) -->
|
||||
<div class="grid grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||
<input type="number" name="slm_tcp_port" value="{{ unit.slm_tcp_port or '2255' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
placeholder="2255">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Control port</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
|
||||
<input type="number" name="slm_ftp_port" value="{{ unit.slm_ftp_port or '21' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
placeholder="21">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Data transfer</p>
|
||||
</div>
|
||||
<div id="direct-ip-field" class="{% if unit.deployed_with_modem_id %}hidden{% endif %}">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Direct IP</label>
|
||||
<input type="text" name="slm_host" value="{{ unit.slm_host or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
placeholder="192.168.1.100">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">If no modem</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" onclick="closeConfigModal()"
|
||||
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" onclick="testSLMConnection('{{ unit.id }}')"
|
||||
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors">
|
||||
Test SLM Connection
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-white bg-seismo-orange hover:bg-seismo-burgundy rounded-lg transition-colors">
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Load modems list for dropdown
|
||||
async function loadModemsForConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/roster/modems');
|
||||
const modems = await response.json();
|
||||
|
||||
const select = document.getElementById('config-modem-select');
|
||||
const currentValue = '{{ unit.deployed_with_modem_id or "" }}';
|
||||
|
||||
// Keep the "No modem" option
|
||||
modems.forEach(modem => {
|
||||
const option = document.createElement('option');
|
||||
option.value = modem.id;
|
||||
const ipText = modem.ip_address ? ' (' + modem.ip_address + ')' : '';
|
||||
option.textContent = modem.id + ipText;
|
||||
if (modem.id === currentValue) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load modems:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle direct IP field and test modem button based on modem selection
|
||||
document.getElementById('config-modem-select')?.addEventListener('change', function() {
|
||||
const directIpField = document.getElementById('direct-ip-field');
|
||||
const testModemBtn = document.getElementById('test-modem-btn');
|
||||
|
||||
if (this.value === '') {
|
||||
directIpField.classList.remove('hidden');
|
||||
testModemBtn.disabled = true;
|
||||
testModemBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
} else {
|
||||
directIpField.classList.add('hidden');
|
||||
testModemBtn.disabled = false;
|
||||
testModemBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle save response
|
||||
function handleConfigSave(event) {
|
||||
if (event.detail.successful) {
|
||||
// Show success message
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Configuration saved successfully!';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
closeConfigModal();
|
||||
// Refresh the unit list
|
||||
htmx.trigger('#slm-list', 'load');
|
||||
}, 2000);
|
||||
} else {
|
||||
// Show error message
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Failed to save configuration';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection to modem (health ping)
|
||||
async function testModemConnection() {
|
||||
const modemSelect = document.getElementById('config-modem-select');
|
||||
const modemId = modemSelect.value;
|
||||
|
||||
if (!modemId) {
|
||||
alert('Please select a modem first');
|
||||
return;
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Pinging modem...';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/slm-dashboard/test-modem/' + modemId);
|
||||
const data = await response.json();
|
||||
|
||||
toast.remove();
|
||||
|
||||
const resultToast = document.createElement('div');
|
||||
if (response.ok && data.status === 'success') {
|
||||
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
const ipAddr = data.ip_address || modemId;
|
||||
const respTime = data.response_time || 'N/A';
|
||||
resultToast.innerHTML = '✓ Modem responding!<br><span class="text-xs">' + ipAddr + ' - ' + respTime + 'ms</span>';
|
||||
} else {
|
||||
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
resultToast.textContent = '⚠ Modem not responding: ' + (data.detail || 'Unknown error');
|
||||
}
|
||||
document.body.appendChild(resultToast);
|
||||
|
||||
setTimeout(() => {
|
||||
resultToast.remove();
|
||||
}, 4000);
|
||||
} catch (error) {
|
||||
toast.remove();
|
||||
const errorToast = document.createElement('div');
|
||||
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
errorToast.textContent = '✗ Failed to ping modem: ' + error.message;
|
||||
document.body.appendChild(errorToast);
|
||||
|
||||
setTimeout(() => {
|
||||
errorToast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection to SLM
|
||||
async function testSLMConnection(unitId) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Testing SLM connection...';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/slmm/' + unitId + '/status');
|
||||
const data = await response.json();
|
||||
|
||||
toast.remove();
|
||||
|
||||
const resultToast = document.createElement('div');
|
||||
if (response.ok && data.status === 'online') {
|
||||
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
resultToast.textContent = '✓ SLM connection successful! ' + (data.model || 'SLM') + ' responding';
|
||||
} else {
|
||||
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
resultToast.textContent = '⚠ SLM connection failed or device offline';
|
||||
}
|
||||
document.body.appendChild(resultToast);
|
||||
|
||||
setTimeout(() => {
|
||||
resultToast.remove();
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
toast.remove();
|
||||
const errorToast = document.createElement('div');
|
||||
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
errorToast.textContent = '✗ SLM connection test failed: ' + error.message;
|
||||
document.body.appendChild(errorToast);
|
||||
|
||||
setTimeout(() => {
|
||||
errorToast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Load modems on page load
|
||||
loadModemsForConfig();
|
||||
</script>
|
||||
215
app/ui/templates/partials/slm_config_form_old.html
Normal file
@@ -0,0 +1,215 @@
|
||||
<form id="slm-config-form"
|
||||
hx-post="/api/slm-dashboard/config/{{ unit.id }}"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="handleConfigSave(event)">
|
||||
|
||||
<div class="mb-6">
|
||||
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Unit: {{ unit.id }}</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Configure measurement parameters for this sound level meter</p>
|
||||
</div>
|
||||
|
||||
<!-- Model & Serial -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
|
||||
<select name="slm_model" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select model...</option>
|
||||
<option value="NL-43" {% if unit.slm_model == 'NL-43' %}selected{% endif %}>NL-43</option>
|
||||
<option value="NL-53" {% if unit.slm_model == 'NL-53' %}selected{% endif %}>NL-53</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
||||
<input type="text" name="slm_serial_number" value="{{ unit.slm_serial_number or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="e.g., SN123456">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frequency & Time Weighting -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
||||
<select name="slm_frequency_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select...</option>
|
||||
<option value="A" {% if unit.slm_frequency_weighting == 'A' %}selected{% endif %}>A-weighting</option>
|
||||
<option value="C" {% if unit.slm_frequency_weighting == 'C' %}selected{% endif %}>C-weighting</option>
|
||||
<option value="Z" {% if unit.slm_frequency_weighting == 'Z' %}selected{% endif %}>Z-weighting (Linear)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
||||
<select name="slm_time_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select...</option>
|
||||
<option value="Fast" {% if unit.slm_time_weighting == 'Fast' %}selected{% endif %}>Fast (125ms)</option>
|
||||
<option value="Slow" {% if unit.slm_time_weighting == 'Slow' %}selected{% endif %}>Slow (1s)</option>
|
||||
<option value="Impulse" {% if unit.slm_time_weighting == 'Impulse' %}selected{% endif %}>Impulse</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Measurement Range -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
||||
<select name="slm_measurement_range" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select range...</option>
|
||||
<option value="30-130" {% if unit.slm_measurement_range == '30-130' %}selected{% endif %}>30-130 dB</option>
|
||||
<option value="40-140" {% if unit.slm_measurement_range == '40-140' %}selected{% endif %}>40-140 dB</option>
|
||||
<option value="50-140" {% if unit.slm_measurement_range == '50-140' %}selected{% endif %}>50-140 dB</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Network Configuration -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-6 mb-4">
|
||||
<h5 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Network Configuration</h5>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned Modem</label>
|
||||
<select name="deployed_with_modem_id" id="config-modem-select" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">No modem (direct connection)</option>
|
||||
<!-- Options loaded via JavaScript -->
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select a modem for network access, or leave blank for direct IP connection</p>
|
||||
</div>
|
||||
|
||||
<!-- Legacy direct connection (shown only if no modem selected) -->
|
||||
<div id="direct-connection-fields" class="{% if unit.deployed_with_modem_id %}hidden{% endif %}">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Direct IP Address</label>
|
||||
<input type="text" name="slm_host" value="{{ unit.slm_host or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
placeholder="192.168.1.100">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||
<input type="number" name="slm_tcp_port" value="{{ unit.slm_tcp_port or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
placeholder="502">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" onclick="closeConfigModal()"
|
||||
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" onclick="testConnection('{{ unit.id }}')"
|
||||
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors">
|
||||
Test Connection
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-white bg-seismo-orange hover:bg-seismo-burgundy rounded-lg transition-colors">
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Load modems list for dropdown
|
||||
async function loadModemsForConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/roster/modems');
|
||||
const modems = await response.json();
|
||||
|
||||
const select = document.getElementById('config-modem-select');
|
||||
const currentValue = '{{ unit.deployed_with_modem_id or "" }}';
|
||||
|
||||
// Keep the "No modem" option
|
||||
modems.forEach(modem => {
|
||||
const option = document.createElement('option');
|
||||
option.value = modem.id;
|
||||
option.textContent = `${modem.id}${modem.ip_address ? ' (' + modem.ip_address + ')' : ''}`;
|
||||
if (modem.id === currentValue) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load modems:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle direct connection fields based on modem selection
|
||||
document.getElementById('config-modem-select')?.addEventListener('change', function() {
|
||||
const directFields = document.getElementById('direct-connection-fields');
|
||||
if (this.value === '') {
|
||||
directFields.classList.remove('hidden');
|
||||
} else {
|
||||
directFields.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle save response
|
||||
function handleConfigSave(event) {
|
||||
if (event.detail.successful) {
|
||||
// Show success message
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Configuration saved successfully!';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
closeConfigModal();
|
||||
// Refresh the unit list
|
||||
htmx.trigger('#slm-list', 'load');
|
||||
}, 2000);
|
||||
} else {
|
||||
// Show error message
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Failed to save configuration';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection to SLM
|
||||
async function testConnection(unitId) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Testing connection...';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/status`);
|
||||
const data = await response.json();
|
||||
|
||||
toast.remove();
|
||||
|
||||
const resultToast = document.createElement('div');
|
||||
if (response.ok && data.status === 'online') {
|
||||
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
resultToast.textContent = `✓ Connection successful! ${data.model || 'SLM'} responding`;
|
||||
} else {
|
||||
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
resultToast.textContent = `Connection failed or device offline`;
|
||||
}
|
||||
document.body.appendChild(resultToast);
|
||||
|
||||
setTimeout(() => {
|
||||
resultToast.remove();
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
toast.remove();
|
||||
const errorToast = document.createElement('div');
|
||||
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
errorToast.textContent = '✗ Connection test failed';
|
||||
document.body.appendChild(errorToast);
|
||||
|
||||
setTimeout(() => {
|
||||
errorToast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Load modems on page load
|
||||
loadModemsForConfig();
|
||||
</script>
|
||||
105
app/ui/templates/partials/slm_controls.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<!-- Status Bar -->
|
||||
<div class="mb-6 p-4 rounded-lg {% if is_measuring %}bg-green-50 dark:bg-green-900/20{% else %}bg-gray-50 dark:bg-gray-900{% endif %}">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Measurement Status</div>
|
||||
<div class="text-2xl font-bold {% if is_measuring %}text-green-600 dark:text-green-400{% else %}text-gray-600 dark:text-gray-400{% endif %}">
|
||||
{% if measurement_state %}
|
||||
{{ measurement_state }}
|
||||
{% if is_measuring %}
|
||||
<span class="inline-block w-3 h-3 bg-green-500 rounded-full ml-2 animate-pulse"></span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Unknown
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Battery</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ battery_level or '--' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<button hx-post="/api/slmm/{{ unit_id }}/start"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||
class="px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2
|
||||
{% if is_measuring %}opacity-50 cursor-not-allowed{% endif %}"
|
||||
{% if is_measuring %}disabled{% endif %}>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
|
||||
<button hx-post="/api/slmm/{{ unit_id }}/stop"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||
class="px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center justify-center gap-2
|
||||
{% if not is_measuring %}opacity-50 cursor-not-allowed{% endif %}"
|
||||
{% if not is_measuring %}disabled{% endif %}>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
|
||||
<button hx-post="/api/slmm/{{ unit_id }}/pause"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||
class="px-4 py-3 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Pause
|
||||
</button>
|
||||
|
||||
<button hx-post="/api/slmm/{{ unit_id }}/reset"
|
||||
hx-swap="none"
|
||||
hx-confirm="Are you sure you want to reset the measurement data?"
|
||||
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||
class="px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button hx-get="/api/slmm/{{ unit_id }}/live"
|
||||
hx-swap="none"
|
||||
hx-indicator="#live-spinner"
|
||||
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center gap-2">
|
||||
<svg id="live-spinner" class="htmx-indicator w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
Get Live Data
|
||||
</button>
|
||||
|
||||
<button hx-post="/api/slmm/{{ unit_id }}/store"
|
||||
hx-swap="none"
|
||||
class="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
|
||||
</svg>
|
||||
Store Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="slm-controls" hx-get="/slm/partials/{{ unit_id }}/controls" hx-trigger="refresh" hx-swap="outerHTML"></div>
|
||||
1462
app/ui/templates/partials/slm_live_view.html
Normal file
438
app/ui/templates/partials/slm_live_view.html.backup
Normal file
@@ -0,0 +1,438 @@
|
||||
<!-- Live View Panel for {{ unit.id }} -->
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ unit.id }}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{% if unit.slm_model %}{{ unit.slm_model }}{% endif %}
|
||||
{% if unit.slm_serial_number %} • S/N: {{ unit.slm_serial_number }}{% endif %}
|
||||
</p>
|
||||
{% if modem %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
via Modem: {{ modem.id }}{% if modem_ip %} ({{ modem_ip }}){% endif %}
|
||||
</p>
|
||||
{% elif modem_ip %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Direct: {{ modem_ip }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-xs text-red-500 dark:text-red-400 mt-1">
|
||||
⚠️ No modem assigned or IP configured
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Measurement Status Badge -->
|
||||
<div>
|
||||
{% if is_measuring %}
|
||||
<span class="px-4 py-2 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 rounded-lg font-medium flex items-center">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||
Measuring
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium">
|
||||
Stopped
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="flex gap-2 mb-6">
|
||||
<button onclick="controlUnit('{{ unit.id }}', 'start')"
|
||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
|
||||
<button onclick="controlUnit('{{ unit.id }}', 'pause')"
|
||||
class="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Pause
|
||||
</button>
|
||||
|
||||
<button onclick="controlUnit('{{ unit.id }}', 'stop')"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
|
||||
<button onclick="controlUnit('{{ unit.id }}', 'reset')"
|
||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<button id="start-stream-btn" onclick="initLiveDataStream('{{ unit.id }}')"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center ml-auto">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
Start Live Stream
|
||||
</button>
|
||||
|
||||
<button id="stop-stream-btn" onclick="stopLiveDataStream()" style="display: none;"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center ml-auto">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||||
</svg>
|
||||
Stop Live Stream
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Current Metrics -->
|
||||
<div class="grid grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</p>
|
||||
<p id="live-lp" class="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{% if current_status and current_status.lp %}{{ current_status.lp }}{% else %}--{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</p>
|
||||
<p id="live-leq" class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{% if current_status and current_status.leq %}{{ current_status.leq }}{% else %}--{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmax (Max)</p>
|
||||
<p id="live-lmax" class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
{% if current_status and current_status.lmax %}{{ current_status.lmax }}{% else %}--{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
|
||||
<p id="live-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
|
||||
<p id="live-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Chart -->
|
||||
<div class="flex-1 bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4" style="min-height: 400px;">
|
||||
<canvas id="liveChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Device Info -->
|
||||
<div class="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-600 dark:text-gray-400">Battery:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||
{% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}--{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600 dark:text-gray-400">Power:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||
{% if current_status and current_status.power_source %}{{ current_status.power_source }}{% else %}--{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600 dark:text-gray-400">Weighting:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||
{% if unit.slm_frequency_weighting %}{{ unit.slm_frequency_weighting }}{% else %}--{% endif %} /
|
||||
{% if unit.slm_time_weighting %}{{ unit.slm_time_weighting }}{% else %}--{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600 dark:text-gray-400">SD Remaining:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||
{% if current_status and current_status.sd_remaining_mb %}{{ current_status.sd_remaining_mb }} MB{% else %}--{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
// Initialize Chart.js for live data visualization
|
||||
function initializeChart() {
|
||||
// Wait for Chart.js to load
|
||||
if (typeof Chart === 'undefined') {
|
||||
console.log('Waiting for Chart.js to load...');
|
||||
setTimeout(initializeChart, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Chart.js loaded, version:', Chart.version);
|
||||
|
||||
const canvas = document.getElementById('liveChart');
|
||||
if (!canvas) {
|
||||
console.error('Chart canvas not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Canvas found:', canvas);
|
||||
|
||||
// Destroy existing chart if it exists
|
||||
if (window.liveChart && typeof window.liveChart.destroy === 'function') {
|
||||
console.log('Destroying existing chart');
|
||||
window.liveChart.destroy();
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
console.log('Creating new chart...');
|
||||
|
||||
// Dark mode detection
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
|
||||
|
||||
window.liveChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Lp (Instantaneous)',
|
||||
data: [],
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0
|
||||
},
|
||||
{
|
||||
label: 'Leq (Equivalent)',
|
||||
data: [],
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
color: gridColor
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
maxTicksLimit: 10
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Sound Level (dB)',
|
||||
color: textColor
|
||||
},
|
||||
grid: {
|
||||
color: gridColor
|
||||
},
|
||||
ticks: {
|
||||
color: textColor
|
||||
},
|
||||
min: 30,
|
||||
max: 130
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: textColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Chart created successfully:', window.liveChart);
|
||||
}
|
||||
|
||||
// Initialize chart when DOM is ready
|
||||
console.log('Executing initializeChart...');
|
||||
initializeChart();
|
||||
|
||||
// WebSocket management (use global scope to avoid redeclaration)
|
||||
if (typeof window.currentWebSocket === 'undefined') {
|
||||
window.currentWebSocket = null;
|
||||
}
|
||||
|
||||
function initLiveDataStream(unitId) {
|
||||
// Close existing connection if any
|
||||
if (window.currentWebSocket) {
|
||||
window.currentWebSocket.close();
|
||||
}
|
||||
|
||||
// Reset chart data
|
||||
if (window.chartData) {
|
||||
window.chartData.timestamps = [];
|
||||
window.chartData.lp = [];
|
||||
window.chartData.leq = [];
|
||||
}
|
||||
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
|
||||
window.liveChart.data.labels = [];
|
||||
window.liveChart.data.datasets[0].data = [];
|
||||
window.liveChart.data.datasets[1].data = [];
|
||||
window.liveChart.update();
|
||||
}
|
||||
|
||||
// WebSocket URL for SLMM backend via proxy
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
||||
|
||||
window.currentWebSocket = new WebSocket(wsUrl);
|
||||
|
||||
window.currentWebSocket.onopen = function() {
|
||||
console.log('WebSocket connected');
|
||||
// Toggle button visibility
|
||||
const startBtn = document.getElementById('start-stream-btn');
|
||||
const stopBtn = document.getElementById('stop-stream-btn');
|
||||
if (startBtn) startBtn.style.display = 'none';
|
||||
if (stopBtn) stopBtn.style.display = 'flex';
|
||||
};
|
||||
|
||||
window.currentWebSocket.onmessage = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('WebSocket data received:', data);
|
||||
updateLiveMetrics(data);
|
||||
updateLiveChart(data);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
window.currentWebSocket.onerror = function(error) {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
window.currentWebSocket.onclose = function() {
|
||||
console.log('WebSocket closed');
|
||||
// Toggle button visibility
|
||||
const startBtn = document.getElementById('start-stream-btn');
|
||||
const stopBtn = document.getElementById('stop-stream-btn');
|
||||
if (startBtn) startBtn.style.display = 'flex';
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
};
|
||||
}
|
||||
|
||||
function stopLiveDataStream() {
|
||||
if (window.currentWebSocket) {
|
||||
window.currentWebSocket.close();
|
||||
window.currentWebSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update metrics display
|
||||
function updateLiveMetrics(data) {
|
||||
if (document.getElementById('live-lp')) {
|
||||
document.getElementById('live-lp').textContent = data.lp || '--';
|
||||
}
|
||||
if (document.getElementById('live-leq')) {
|
||||
document.getElementById('live-leq').textContent = data.leq || '--';
|
||||
}
|
||||
if (document.getElementById('live-lmax')) {
|
||||
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
||||
}
|
||||
if (document.getElementById('live-lmin')) {
|
||||
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
||||
}
|
||||
if (document.getElementById('live-lpeak')) {
|
||||
document.getElementById('live-lpeak').textContent = data.lpeak || '--';
|
||||
}
|
||||
}
|
||||
|
||||
// Chart data storage (use global scope to avoid redeclaration)
|
||||
if (typeof window.chartData === 'undefined') {
|
||||
window.chartData = {
|
||||
timestamps: [],
|
||||
lp: [],
|
||||
leq: []
|
||||
};
|
||||
}
|
||||
|
||||
// Update live chart
|
||||
function updateLiveChart(data) {
|
||||
const now = new Date();
|
||||
window.chartData.timestamps.push(now.toLocaleTimeString());
|
||||
window.chartData.lp.push(parseFloat(data.lp || 0));
|
||||
window.chartData.leq.push(parseFloat(data.leq || 0));
|
||||
|
||||
// Keep only last 60 data points
|
||||
if (window.chartData.timestamps.length > 60) {
|
||||
window.chartData.timestamps.shift();
|
||||
window.chartData.lp.shift();
|
||||
window.chartData.leq.shift();
|
||||
}
|
||||
|
||||
// Update chart if available
|
||||
if (window.liveChart) {
|
||||
window.liveChart.data.labels = window.chartData.timestamps;
|
||||
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
||||
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
||||
window.liveChart.update('none');
|
||||
}
|
||||
}
|
||||
|
||||
// Control function
|
||||
async function controlUnit(unitId, action) {
|
||||
try {
|
||||
const response = await fetch(`/api/slm-dashboard/control/${unitId}/${action}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'ok') {
|
||||
// Reload the live view to update status
|
||||
setTimeout(() => {
|
||||
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
||||
target: '#live-view-panel',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}, 500);
|
||||
} else {
|
||||
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Failed to control unit: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window.currentWebSocket) {
|
||||
window.currentWebSocket.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
8
app/ui/templates/partials/slm_live_view_error.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<!-- Error State for Live View -->
|
||||
<div class="flex flex-col items-center justify-center h-[600px] text-red-500 dark:text-red-400">
|
||||
<svg class="w-24 h-24 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<p class="text-lg font-medium">Error Loading Unit</p>
|
||||
<p class="text-sm mt-2 text-gray-600 dark:text-gray-400">{{ error }}</p>
|
||||
</div>
|
||||
61
app/ui/templates/partials/slm_stats.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!-- Summary stat cards -->
|
||||
<!-- Total Units Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Total Units</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ total_count }}</p>
|
||||
</div>
|
||||
<div class="bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg">
|
||||
<svg class="w-8 h-8 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deployed Units Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Deployed</p>
|
||||
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">{{ deployed_count }}</p>
|
||||
</div>
|
||||
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
|
||||
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Now Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Active Now</p>
|
||||
<p class="text-3xl font-bold text-seismo-orange mt-1">{{ active_count }}</p>
|
||||
</div>
|
||||
<div class="bg-orange-100 dark:bg-orange-900/30 p-3 rounded-lg">
|
||||
<svg class="w-8 h-8 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Checked in last hour</p>
|
||||
</div>
|
||||
|
||||
<!-- Benched Units Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Benched</p>
|
||||
<p class="text-3xl font-bold text-gray-500 dark:text-gray-400 mt-1">{{ benched_count }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 p-3 rounded-lg">
|
||||
<svg class="w-8 h-8 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
73
app/ui/templates/partials/slm_unit_list.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!-- SLM Unit List -->
|
||||
{% if units %}
|
||||
{% for unit in units %}
|
||||
<div class="slm-unit-item bg-gray-100 dark:bg-gray-700 rounded-lg p-4 transition-colors relative group">
|
||||
<!-- Configure button (appears on hover) -->
|
||||
<button onclick="event.stopPropagation(); openConfigModal('{{ unit.id }}');"
|
||||
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full p-1.5 hover:bg-gray-200 dark:hover:bg-gray-600 z-10"
|
||||
title="Configure {{ unit.id }}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="cursor-pointer" onclick="selectUnit('{{ unit.id }}')">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-semibold">{{ unit.id }}</span>
|
||||
<!-- Status indicator: green=active, yellow=recent, red=old, gray=never -->
|
||||
{% if unit.slm_last_check %}
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full" title="Active"></span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 bg-gray-400 rounded-full" title="No check-in"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-sm space-y-1">
|
||||
{% if unit.slm_model %}
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
|
||||
</svg>
|
||||
{{ unit.slm_model }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if unit.address %}
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<span class="truncate">{{ unit.address }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
<div class="flex items-center font-mono text-xs">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
{{ unit.deployed_with_modem_id }}
|
||||
</div>
|
||||
{% elif unit.slm_host %}
|
||||
<div class="flex items-center font-mono text-xs">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
|
||||
</svg>
|
||||
{{ unit.slm_host }}{% if unit.slm_tcp_port %}:{{ unit.slm_tcp_port }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
<p>No sound level meters found</p>
|
||||
<p class="text-sm mt-1">Add units from the Fleet Roster</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
61
app/ui/templates/partials/unknown_emitters.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% if unknown_units %}
|
||||
<div class="mb-6 rounded-xl shadow-lg bg-yellow-50 dark:bg-yellow-900/20 border-2 border-yellow-400 dark:border-yellow-600 p-6">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<svg class="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-bold text-yellow-900 dark:text-yellow-200">Unknown Emitters Detected</h2>
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-300 mt-1">
|
||||
{{ unknown_units|length }} unit(s) are reporting but not in the roster. Add them to track them properly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
{% for unit in unknown_units %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 flex items-center justify-between border border-yellow-300 dark:border-yellow-700">
|
||||
<div class="flex items-center gap-4 flex-1">
|
||||
<div class="font-mono font-bold text-lg text-gray-900 dark:text-white">
|
||||
{{ unit.id }}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="px-2 py-1 rounded-full
|
||||
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||
{% else %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300{% endif %}">
|
||||
{{ unit.status }}
|
||||
</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
Last seen: {{ unit.age }}
|
||||
</span>
|
||||
{% if unit.fname %}
|
||||
<span class="text-gray-500 dark:text-gray-500 text-xs truncate max-w-xs">
|
||||
{{ unit.fname }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 flex gap-2">
|
||||
<button
|
||||
onclick="addUnknownUnit('{{ unit.id }}')"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center gap-2 transition-colors whitespace-nowrap">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Add to Roster
|
||||
</button>
|
||||
<button
|
||||
onclick="ignoreUnknownUnit('{{ unit.id }}')"
|
||||
class="px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-lg flex items-center gap-2 transition-colors whitespace-nowrap">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||
</svg>
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
1137
app/ui/templates/roster.html
Normal file
76
app/ui/templates/seismographs.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Seismographs - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Seismographs</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage and monitor seismograph units</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto-refresh stats every 30 seconds -->
|
||||
<div hx-get="/api/seismo-dashboard/stats"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML"
|
||||
class="mb-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Loading...</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">--</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seismograph List -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
|
||||
|
||||
<!-- Search Box -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="seismo-search"
|
||||
placeholder="Search seismographs..."
|
||||
class="w-full sm:w-64 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
hx-get="/api/seismo-dashboard/units"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#seismo-units-list"
|
||||
hx-include="[name='search']"
|
||||
name="search"
|
||||
/>
|
||||
<svg class="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Units List (loaded via HTMX) -->
|
||||
<div id="seismo-units-list"
|
||||
hx-get="/api/seismo-dashboard/units"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading seismographs...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Clear search input on escape key
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('seismo-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
this.value = '';
|
||||
htmx.trigger(this, 'keyup');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
1376
app/ui/templates/settings.html
Normal file
195
app/ui/templates/slm_detail.html
Normal file
@@ -0,0 +1,195 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ unit_id }} - Sound Level Meter{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<a href="/roster" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Back to Roster
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3">
|
||||
</path>
|
||||
</svg>
|
||||
{{ unit_id }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{{ unit.slm_model or 'NL-43' }} Sound Level Meter
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span class="px-3 py-1 rounded-full text-sm font-medium
|
||||
{% if unit.deployed %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||
{% if unit.deployed %}Deployed{% else %}Benched{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Panel -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Control Panel</h2>
|
||||
<div hx-get="/slm/partials/{{ unit_id }}/controls"
|
||||
hx-trigger="load, every 5s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading controls...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Real-time Data Stream -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Real-time Measurements</h2>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div id="slm-stream-container">
|
||||
<div class="text-center py-8">
|
||||
<button onclick="startStream()"
|
||||
id="stream-start-btn"
|
||||
class="px-6 py-3 bg-seismo-orange text-white rounded-lg hover:bg-seismo-orange-dark transition-colors">
|
||||
Start Real-time Stream
|
||||
</button>
|
||||
<p class="text-sm text-gray-500 mt-2">Click to begin streaming live measurement data</p>
|
||||
</div>
|
||||
<div id="stream-data" class="hidden">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</div>
|
||||
<div id="stream-lp" class="text-3xl font-bold text-gray-900 dark:text-white">--</div>
|
||||
<div class="text-xs text-gray-500">dB</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</div>
|
||||
<div id="stream-leq" class="text-3xl font-bold text-blue-600 dark:text-blue-400">--</div>
|
||||
<div class="text-xs text-gray-500">dB</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmax</div>
|
||||
<div id="stream-lmax" class="text-3xl font-bold text-red-600 dark:text-red-400">--</div>
|
||||
<div class="text-xs text-gray-500">dB</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmin</div>
|
||||
<div id="stream-lmin" class="text-3xl font-bold text-green-600 dark:text-green-400">--</div>
|
||||
<div class="text-xs text-gray-500">dB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-xs text-gray-500">
|
||||
<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||
Streaming
|
||||
</div>
|
||||
<button onclick="stopStream()"
|
||||
class="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors">
|
||||
Stop Stream
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Information -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Device Information</h2>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Model</div>
|
||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_model or 'NL-43' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Serial Number</div>
|
||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_serial_number or 'N/A' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Host</div>
|
||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_host or 'Not configured' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">TCP Port</div>
|
||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_tcp_port or 'N/A' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Frequency Weighting</div>
|
||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_frequency_weighting or 'A' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Time Weighting</div>
|
||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_time_weighting or 'F (Fast)' }}</div>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Location</div>
|
||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.address or unit.location or 'Not specified' }}</div>
|
||||
</div>
|
||||
{% if unit.note %}
|
||||
<div class="md:col-span-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
||||
<div class="text-gray-900 dark:text-white">{{ unit.note }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let ws = null;
|
||||
|
||||
function startStream() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/slmm/{{ unit_id }}/stream`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
document.getElementById('stream-start-btn').classList.add('hidden');
|
||||
document.getElementById('stream-data').classList.remove('hidden');
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.error) {
|
||||
console.error('Stream error:', data.error);
|
||||
stopStream();
|
||||
alert('Error: ' + data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update values
|
||||
document.getElementById('stream-lp').textContent = data.lp || '--';
|
||||
document.getElementById('stream-leq').textContent = data.leq || '--';
|
||||
document.getElementById('stream-lmax').textContent = data.lmax || '--';
|
||||
document.getElementById('stream-lmin').textContent = data.lmin || '--';
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
stopStream();
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket closed');
|
||||
};
|
||||
}
|
||||
|
||||
function stopStream() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
document.getElementById('stream-start-btn').classList.remove('hidden');
|
||||
document.getElementById('stream-data').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
249
app/ui/templates/sound_level_meters.html
Normal file
@@ -0,0 +1,249 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Sound Level Meters - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sound Level Meters</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Monitor and manage sound level measurement devices</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
|
||||
hx-get="/api/slm-dashboard/stats"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-swap="innerHTML">
|
||||
<!-- Stats will be loaded here -->
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- SLM List -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Units</h2>
|
||||
|
||||
<!-- Search/Filter -->
|
||||
<div class="mb-4">
|
||||
<input type="text"
|
||||
placeholder="Search units..."
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
hx-get="/api/slm-dashboard/units"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#slm-list"
|
||||
hx-include="this"
|
||||
name="search">
|
||||
</div>
|
||||
|
||||
<!-- SLM List -->
|
||||
<div id="slm-list"
|
||||
class="space-y-2 max-h-[600px] overflow-y-auto"
|
||||
hx-get="/api/slm-dashboard/units"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-swap="innerHTML">
|
||||
<!-- Loading skeleton -->
|
||||
<div class="animate-pulse space-y-2">
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live View Panel -->
|
||||
<div class="lg:col-span-2">
|
||||
<div id="live-view-panel" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<!-- Initial state - no unit selected -->
|
||||
<div class="flex flex-col items-center justify-center h-[600px] text-gray-400 dark:text-gray-500">
|
||||
<svg class="w-24 h-24 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||
</svg>
|
||||
<p class="text-lg font-medium">No unit selected</p>
|
||||
<p class="text-sm mt-2">Select a sound level meter from the list to view live data</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Modal -->
|
||||
<div id="config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Configure SLM</h3>
|
||||
<button onclick="closeConfigModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="config-modal-content">
|
||||
<!-- Content loaded via HTMX -->
|
||||
<div class="animate-pulse space-y-4">
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Function to select a unit and load live view
|
||||
function selectUnit(unitId) {
|
||||
// Remove active state from all items
|
||||
document.querySelectorAll('.slm-unit-item').forEach(item => {
|
||||
item.classList.remove('bg-seismo-orange', 'text-white');
|
||||
item.classList.add('bg-gray-100', 'dark:bg-gray-700');
|
||||
});
|
||||
|
||||
// Add active state to clicked item
|
||||
event.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700');
|
||||
event.currentTarget.classList.add('bg-seismo-orange', 'text-white');
|
||||
|
||||
// Load live view for this unit
|
||||
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
||||
target: '#live-view-panel',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
|
||||
// Configuration modal functions
|
||||
function openConfigModal(unitId) {
|
||||
const modal = document.getElementById('config-modal');
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Load configuration form via HTMX
|
||||
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
||||
target: '#config-modal-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
|
||||
function closeConfigModal() {
|
||||
document.getElementById('config-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Close modal on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeConfigModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('config-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeConfigModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize WebSocket for selected unit
|
||||
let currentWebSocket = null;
|
||||
|
||||
function initLiveDataStream(unitId) {
|
||||
// Close existing connection if any
|
||||
if (currentWebSocket) {
|
||||
currentWebSocket.close();
|
||||
}
|
||||
|
||||
// WebSocket URL for SLMM backend via proxy
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
||||
|
||||
currentWebSocket = new WebSocket(wsUrl);
|
||||
|
||||
currentWebSocket.onopen = function() {
|
||||
console.log('WebSocket connected');
|
||||
// Toggle button visibility
|
||||
const startBtn = document.getElementById('start-stream-btn');
|
||||
const stopBtn = document.getElementById('stop-stream-btn');
|
||||
if (startBtn) startBtn.style.display = 'none';
|
||||
if (stopBtn) stopBtn.style.display = 'flex';
|
||||
};
|
||||
|
||||
currentWebSocket.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
updateLiveChart(data);
|
||||
updateLiveMetrics(data);
|
||||
};
|
||||
|
||||
currentWebSocket.onerror = function(error) {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
currentWebSocket.onclose = function() {
|
||||
console.log('WebSocket closed');
|
||||
// Toggle button visibility
|
||||
const startBtn = document.getElementById('start-stream-btn');
|
||||
const stopBtn = document.getElementById('stop-stream-btn');
|
||||
if (startBtn) startBtn.style.display = 'flex';
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
};
|
||||
}
|
||||
|
||||
function stopLiveDataStream() {
|
||||
if (currentWebSocket) {
|
||||
currentWebSocket.close();
|
||||
currentWebSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update live chart with new data point
|
||||
let chartData = {
|
||||
timestamps: [],
|
||||
lp: [],
|
||||
leq: []
|
||||
};
|
||||
|
||||
function updateLiveChart(data) {
|
||||
const now = new Date();
|
||||
chartData.timestamps.push(now.toLocaleTimeString());
|
||||
chartData.lp.push(parseFloat(data.lp || 0));
|
||||
chartData.leq.push(parseFloat(data.leq || 0));
|
||||
|
||||
// Keep only last 60 data points (1 minute at 1 sample/sec)
|
||||
if (chartData.timestamps.length > 60) {
|
||||
chartData.timestamps.shift();
|
||||
chartData.lp.shift();
|
||||
chartData.leq.shift();
|
||||
}
|
||||
|
||||
// Update chart (using Chart.js if available)
|
||||
if (window.liveChart) {
|
||||
window.liveChart.data.labels = chartData.timestamps;
|
||||
window.liveChart.data.datasets[0].data = chartData.lp;
|
||||
window.liveChart.data.datasets[1].data = chartData.leq;
|
||||
window.liveChart.update('none'); // Update without animation for smooth real-time
|
||||
}
|
||||
}
|
||||
|
||||
function updateLiveMetrics(data) {
|
||||
// Update metric displays
|
||||
if (document.getElementById('live-lp')) {
|
||||
document.getElementById('live-lp').textContent = data.lp || '--';
|
||||
}
|
||||
if (document.getElementById('live-leq')) {
|
||||
document.getElementById('live-leq').textContent = data.leq || '--';
|
||||
}
|
||||
if (document.getElementById('live-lmax')) {
|
||||
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
||||
}
|
||||
if (document.getElementById('live-lmin')) {
|
||||
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (currentWebSocket) {
|
||||
currentWebSocket.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
1008
app/ui/templates/unit_detail.html
Normal file
@@ -524,4 +524,6 @@ def health_check():
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
import os
|
||||
port = int(os.getenv("PORT", 8001))
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
|
||||
@@ -4,14 +4,14 @@ services:
|
||||
terra-view-prod:
|
||||
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:
|
||||
|
||||
@@ -23,8 +23,20 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Measurement Status Badge -->
|
||||
<div>
|
||||
<!-- 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>
|
||||
@@ -37,6 +49,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="flex gap-2 mb-6">
|
||||
@@ -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,6 +429,7 @@ 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)';
|
||||
|
||||
try {
|
||||
window.liveChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
@@ -318,11 +502,32 @@ function initializeChart() {
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||