feat: Add Rename Unit functionality and improve navigation in SLM dashboard
- Implemented a modal for renaming units with validation and confirmation prompts. - Added JavaScript functions to handle opening, closing, and submitting the rename unit form. - Enhanced the back navigation in the SLM detail page to check referrer history. - Updated breadcrumb navigation in the legacy dashboard to accommodate NRL locations. - Improved the sound level meters page with a more informative header and device list. - Introduced a live measurement chart with WebSocket support for real-time data streaming. - Added functionality to manage active devices and projects with auto-refresh capabilities.
This commit is contained in:
@@ -18,7 +18,7 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from backend.database import engine, Base, get_db
|
from backend.database import engine, Base, get_db
|
||||||
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler
|
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
from backend.models import IgnoredUnit
|
from backend.models import IgnoredUnit
|
||||||
|
|
||||||
@@ -84,6 +84,7 @@ app.include_router(roster.router)
|
|||||||
app.include_router(units.router)
|
app.include_router(units.router)
|
||||||
app.include_router(photos.router)
|
app.include_router(photos.router)
|
||||||
app.include_router(roster_edit.router)
|
app.include_router(roster_edit.router)
|
||||||
|
app.include_router(roster_rename.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(dashboard_tabs.router)
|
app.include_router(dashboard_tabs.router)
|
||||||
app.include_router(activity.router)
|
app.include_router(activity.router)
|
||||||
@@ -162,6 +163,7 @@ async def slm_legacy_dashboard(
|
|||||||
request: Request,
|
request: Request,
|
||||||
unit_id: str,
|
unit_id: str,
|
||||||
from_project: Optional[str] = None,
|
from_project: Optional[str] = None,
|
||||||
|
from_nrl: Optional[str] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Legacy SLM control center dashboard for a specific unit"""
|
"""Legacy SLM control center dashboard for a specific unit"""
|
||||||
@@ -171,11 +173,19 @@ async def slm_legacy_dashboard(
|
|||||||
from backend.models import Project
|
from backend.models import Project
|
||||||
project = db.query(Project).filter_by(id=from_project).first()
|
project = db.query(Project).filter_by(id=from_project).first()
|
||||||
|
|
||||||
|
# Get NRL location details if from_nrl is provided
|
||||||
|
nrl_location = None
|
||||||
|
if from_nrl:
|
||||||
|
from backend.models import NRLLocation
|
||||||
|
nrl_location = db.query(NRLLocation).filter_by(id=from_nrl).first()
|
||||||
|
|
||||||
return templates.TemplateResponse("slm_legacy_dashboard.html", {
|
return templates.TemplateResponse("slm_legacy_dashboard.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"from_project": from_project,
|
"from_project": from_project,
|
||||||
"project": project
|
"from_nrl": from_nrl,
|
||||||
|
"project": project,
|
||||||
|
"nrl_location": nrl_location
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
139
backend/routers/roster_rename.py
Normal file
139
backend/routers/roster_rename.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
Roster Unit Rename Router
|
||||||
|
|
||||||
|
Provides endpoint for safely renaming unit IDs across all database tables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Form
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import RosterUnit, Emitter, UnitHistory
|
||||||
|
from backend.routers.roster_edit import record_history, sync_slm_to_slmm_cache
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/roster", tags=["roster-rename"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rename")
|
||||||
|
async def rename_unit(
|
||||||
|
old_id: str = Form(...),
|
||||||
|
new_id: str = Form(...),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Rename a unit ID across all tables.
|
||||||
|
Updates the unit ID in roster, emitters, unit_history, and all foreign key references.
|
||||||
|
|
||||||
|
IMPORTANT: This operation updates the primary key, which affects all relationships.
|
||||||
|
"""
|
||||||
|
# Validate input
|
||||||
|
if not old_id or not new_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Both old_id and new_id are required")
|
||||||
|
|
||||||
|
if old_id == new_id:
|
||||||
|
raise HTTPException(status_code=400, detail="New ID must be different from old ID")
|
||||||
|
|
||||||
|
# Check if old unit exists
|
||||||
|
old_unit = db.query(RosterUnit).filter(RosterUnit.id == old_id).first()
|
||||||
|
if not old_unit:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unit '{old_id}' not found")
|
||||||
|
|
||||||
|
# Check if new ID already exists
|
||||||
|
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == new_id).first()
|
||||||
|
if existing_unit:
|
||||||
|
raise HTTPException(status_code=409, detail=f"Unit ID '{new_id}' already exists")
|
||||||
|
|
||||||
|
device_type = old_unit.device_type
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Record history for the rename operation (using old_id since that's still valid)
|
||||||
|
record_history(
|
||||||
|
db=db,
|
||||||
|
unit_id=old_id,
|
||||||
|
change_type="id_change",
|
||||||
|
field_name="id",
|
||||||
|
old_value=old_id,
|
||||||
|
new_value=new_id,
|
||||||
|
source="manual",
|
||||||
|
notes=f"Unit renamed from '{old_id}' to '{new_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update roster table (primary)
|
||||||
|
old_unit.id = new_id
|
||||||
|
old_unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Update emitters table
|
||||||
|
emitter = db.query(Emitter).filter(Emitter.id == old_id).first()
|
||||||
|
if emitter:
|
||||||
|
emitter.id = new_id
|
||||||
|
|
||||||
|
# Update unit_history table (all entries for this unit)
|
||||||
|
db.query(UnitHistory).filter(UnitHistory.unit_id == old_id).update(
|
||||||
|
{"unit_id": new_id},
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update deployed_with_modem_id references (units that reference this as modem)
|
||||||
|
db.query(RosterUnit).filter(RosterUnit.deployed_with_modem_id == old_id).update(
|
||||||
|
{"deployed_with_modem_id": new_id},
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update unit_assignments table (if exists)
|
||||||
|
try:
|
||||||
|
from backend.models import UnitAssignment
|
||||||
|
db.query(UnitAssignment).filter(UnitAssignment.unit_id == old_id).update(
|
||||||
|
{"unit_id": new_id},
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not update unit_assignments: {e}")
|
||||||
|
|
||||||
|
# Update recording_sessions table (if exists)
|
||||||
|
try:
|
||||||
|
from backend.models import RecordingSession
|
||||||
|
db.query(RecordingSession).filter(RecordingSession.unit_id == old_id).update(
|
||||||
|
{"unit_id": new_id},
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not update recording_sessions: {e}")
|
||||||
|
|
||||||
|
# Commit all changes
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# If sound level meter, sync updated config to SLMM cache
|
||||||
|
if device_type == "sound_level_meter":
|
||||||
|
logger.info(f"Syncing renamed SLM {new_id} (was {old_id}) config to SLMM cache...")
|
||||||
|
result = await sync_slm_to_slmm_cache(
|
||||||
|
unit_id=new_id,
|
||||||
|
host=old_unit.slm_host,
|
||||||
|
tcp_port=old_unit.slm_tcp_port,
|
||||||
|
ftp_port=old_unit.slm_ftp_port,
|
||||||
|
deployed_with_modem_id=old_unit.deployed_with_modem_id,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning(f"SLMM cache sync warning for renamed unit {new_id}: {result['message']}")
|
||||||
|
|
||||||
|
logger.info(f"Successfully renamed unit '{old_id}' to '{new_id}'")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Successfully renamed unit from '{old_id}' to '{new_id}'",
|
||||||
|
"old_id": old_id,
|
||||||
|
"new_id": new_id,
|
||||||
|
"device_type": device_type
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error renaming unit '{old_id}' to '{new_id}': {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to rename unit: {str(e)}"
|
||||||
|
)
|
||||||
@@ -5,6 +5,7 @@ from typing import Dict, Any
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["units"])
|
router = APIRouter(prefix="/api", tags=["units"])
|
||||||
|
|
||||||
@@ -42,3 +43,32 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"coordinates": coords
|
"coordinates": coords
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/units/{unit_id}")
|
||||||
|
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get unit data directly from the roster (for settings/configuration).
|
||||||
|
"""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": unit.id,
|
||||||
|
"unit_type": unit.unit_type,
|
||||||
|
"device_type": unit.device_type,
|
||||||
|
"deployed": unit.deployed,
|
||||||
|
"retired": unit.retired,
|
||||||
|
"note": unit.note,
|
||||||
|
"location": unit.location,
|
||||||
|
"address": unit.address,
|
||||||
|
"coordinates": unit.coordinates,
|
||||||
|
"slm_host": unit.slm_host,
|
||||||
|
"slm_tcp_port": unit.slm_tcp_port,
|
||||||
|
"slm_ftp_port": unit.slm_ftp_port,
|
||||||
|
"slm_model": unit.slm_model,
|
||||||
|
"slm_serial_number": unit.slm_serial_number,
|
||||||
|
"deployed_with_modem_id": unit.deployed_with_modem_id
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=production
|
||||||
- SLMM_BASE_URL=http://slmm:8100
|
- SLMM_BASE_URL=http://host.docker.internal:8100
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- slmm
|
- slmm
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -50,8 +52,7 @@ services:
|
|||||||
context: ../slmm
|
context: ../slmm
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: slmm
|
container_name: slmm
|
||||||
ports:
|
network_mode: host
|
||||||
- "8100:8100"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ../slmm/data:/app/data
|
- ../slmm/data:/app/data
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
138
rename_unit.py
Normal file
138
rename_unit.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script to rename a unit ID in the database.
|
||||||
|
This updates the unit across all tables with proper foreign key handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
DATABASE_URL = "sqlite:///data/sfm.db"
|
||||||
|
|
||||||
|
def rename_unit(old_id: str, new_id: str):
|
||||||
|
"""
|
||||||
|
Rename a unit ID across all relevant tables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_id: Current unit ID (e.g., "SLM4301")
|
||||||
|
new_id: New unit ID (e.g., "SLM-43-01")
|
||||||
|
"""
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if old unit exists
|
||||||
|
result = session.execute(
|
||||||
|
text("SELECT id, device_type FROM roster WHERE id = :old_id"),
|
||||||
|
{"old_id": old_id}
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
print(f"❌ Error: Unit '{old_id}' not found in roster")
|
||||||
|
return False
|
||||||
|
|
||||||
|
device_type = result[1]
|
||||||
|
print(f"✓ Found unit '{old_id}' (device_type: {device_type})")
|
||||||
|
|
||||||
|
# Check if new ID already exists
|
||||||
|
result = session.execute(
|
||||||
|
text("SELECT id FROM roster WHERE id = :new_id"),
|
||||||
|
{"new_id": new_id}
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
print(f"❌ Error: Unit ID '{new_id}' already exists")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"\n🔄 Renaming '{old_id}' → '{new_id}'...\n")
|
||||||
|
|
||||||
|
# Update roster table (primary)
|
||||||
|
session.execute(
|
||||||
|
text("UPDATE roster SET id = :new_id WHERE id = :old_id"),
|
||||||
|
{"new_id": new_id, "old_id": old_id}
|
||||||
|
)
|
||||||
|
print(f" ✓ Updated roster")
|
||||||
|
|
||||||
|
# Update emitters table
|
||||||
|
result = session.execute(
|
||||||
|
text("UPDATE emitters SET id = :new_id WHERE id = :old_id"),
|
||||||
|
{"new_id": new_id, "old_id": old_id}
|
||||||
|
)
|
||||||
|
if result.rowcount > 0:
|
||||||
|
print(f" ✓ Updated emitters ({result.rowcount} rows)")
|
||||||
|
|
||||||
|
# Update unit_history table
|
||||||
|
result = session.execute(
|
||||||
|
text("UPDATE unit_history SET unit_id = :new_id WHERE unit_id = :old_id"),
|
||||||
|
{"new_id": new_id, "old_id": old_id}
|
||||||
|
)
|
||||||
|
if result.rowcount > 0:
|
||||||
|
print(f" ✓ Updated unit_history ({result.rowcount} rows)")
|
||||||
|
|
||||||
|
# Update deployed_with_modem_id references
|
||||||
|
result = session.execute(
|
||||||
|
text("UPDATE roster SET deployed_with_modem_id = :new_id WHERE deployed_with_modem_id = :old_id"),
|
||||||
|
{"new_id": new_id, "old_id": old_id}
|
||||||
|
)
|
||||||
|
if result.rowcount > 0:
|
||||||
|
print(f" ✓ Updated modem references ({result.rowcount} rows)")
|
||||||
|
|
||||||
|
# Update unit_assignments table (if exists)
|
||||||
|
try:
|
||||||
|
result = session.execute(
|
||||||
|
text("UPDATE unit_assignments SET unit_id = :new_id WHERE unit_id = :old_id"),
|
||||||
|
{"new_id": new_id, "old_id": old_id}
|
||||||
|
)
|
||||||
|
if result.rowcount > 0:
|
||||||
|
print(f" ✓ Updated unit_assignments ({result.rowcount} rows)")
|
||||||
|
except Exception:
|
||||||
|
pass # Table may not exist
|
||||||
|
|
||||||
|
# Update recording_sessions table (if exists)
|
||||||
|
try:
|
||||||
|
result = session.execute(
|
||||||
|
text("UPDATE recording_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
|
||||||
|
{"new_id": new_id, "old_id": old_id}
|
||||||
|
)
|
||||||
|
if result.rowcount > 0:
|
||||||
|
print(f" ✓ Updated recording_sessions ({result.rowcount} rows)")
|
||||||
|
except Exception:
|
||||||
|
pass # Table may not exist
|
||||||
|
|
||||||
|
# Commit all changes
|
||||||
|
session.commit()
|
||||||
|
print(f"\n✅ Successfully renamed unit '{old_id}' to '{new_id}'")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
print(f"\n❌ Error during rename: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("Usage: python rename_unit.py <old_id> <new_id>")
|
||||||
|
print("Example: python rename_unit.py SLM4301 SLM-43-01")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
old_id = sys.argv[1]
|
||||||
|
new_id = sys.argv[2]
|
||||||
|
|
||||||
|
print(f"Unit Renaming Tool")
|
||||||
|
print(f"=" * 50)
|
||||||
|
print(f"Old ID: {old_id}")
|
||||||
|
print(f"New ID: {new_id}")
|
||||||
|
print(f"=" * 50)
|
||||||
|
|
||||||
|
confirm = input(f"\nAre you sure you want to rename '{old_id}' to '{new_id}'? (yes/no): ")
|
||||||
|
if confirm.lower() != 'yes':
|
||||||
|
print("❌ Rename cancelled")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
success = rename_unit(old_id, new_id)
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
<a href="/slm/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
<a href="/slm/{{ assigned_unit.id }}?from_project={{ project_id }}&from_nrl={{ location_id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
{{ assigned_unit.id }}
|
{{ assigned_unit.id }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
<!-- SLM Device List -->
|
<!-- SLM Device List -->
|
||||||
{% if units %}
|
{% if units %}
|
||||||
{% for unit in units %}
|
{% for unit in units %}
|
||||||
<a href="/slm/{{ unit.id }}" class="block bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
|
||||||
|
<div class="absolute top-3 right-3 flex gap-2">
|
||||||
|
<button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');"
|
||||||
|
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
||||||
|
title="View live chart">
|
||||||
|
<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="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>
|
||||||
|
</button>
|
||||||
<button onclick="event.preventDefault(); event.stopPropagation(); openDeviceConfigModal('{{ unit.id }}');"
|
<button onclick="event.preventDefault(); event.stopPropagation(); openDeviceConfigModal('{{ unit.id }}');"
|
||||||
class="absolute top-3 right-3 text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
||||||
title="Configure {{ unit.id }}">
|
title="Configure {{ unit.id }}">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="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>
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/slm/{{ unit.id }}" class="block">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
||||||
{% if unit.slm_model %}
|
{% if unit.slm_model %}
|
||||||
@@ -46,6 +57,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
|||||||
@@ -23,6 +23,27 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Status and Actions -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Settings Gear -->
|
||||||
|
<button onclick="openSettingsModal('{{ unit.id }}')"
|
||||||
|
class="p-2 text-gray-600 dark:text-gray-400 hover:text-seismo-orange dark:hover:text-seismo-orange rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
title="Unit Settings">
|
||||||
|
<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="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>
|
||||||
|
|
||||||
|
<!-- FTP Browser -->
|
||||||
|
<button onclick="openFTPBrowser('{{ unit.id }}')"
|
||||||
|
class="p-2 text-gray-600 dark:text-gray-400 hover:text-seismo-orange dark:hover:text-seismo-orange rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
title="Browse Files (FTP)">
|
||||||
|
<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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Measurement Status Badge -->
|
<!-- Measurement Status Badge -->
|
||||||
<div>
|
<div>
|
||||||
{% if is_measuring %}
|
{% if is_measuring %}
|
||||||
@@ -37,6 +58,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Control Buttons -->
|
<!-- Control Buttons -->
|
||||||
<div class="flex gap-2 mb-6">
|
<div class="flex gap-2 mb-6">
|
||||||
@@ -564,4 +586,864 @@ window.addEventListener('beforeunload', function() {
|
|||||||
window.currentWebSocket.close();
|
window.currentWebSocket.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Settings Modal
|
||||||
|
// ========================================
|
||||||
|
async function openSettingsModal(unitId) {
|
||||||
|
const modal = document.getElementById('settings-modal');
|
||||||
|
const errorDiv = document.getElementById('settings-error');
|
||||||
|
const successDiv = document.getElementById('settings-success');
|
||||||
|
|
||||||
|
// Clear previous messages
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
successDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
// Store unit ID
|
||||||
|
document.getElementById('settings-unit-id').value = unitId;
|
||||||
|
|
||||||
|
// Load current SLMM config
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/config`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
const config = result.data || {};
|
||||||
|
|
||||||
|
// Populate form fields
|
||||||
|
document.getElementById('settings-host').value = config.host || '';
|
||||||
|
document.getElementById('settings-tcp-port').value = config.tcp_port || 2255;
|
||||||
|
document.getElementById('settings-ftp-port').value = config.ftp_port || 21;
|
||||||
|
document.getElementById('settings-ftp-username').value = config.ftp_username || '';
|
||||||
|
document.getElementById('settings-ftp-password').value = config.ftp_password || '';
|
||||||
|
document.getElementById('settings-tcp-enabled').checked = config.tcp_enabled !== false;
|
||||||
|
document.getElementById('settings-ftp-enabled').checked = config.ftp_enabled === true;
|
||||||
|
document.getElementById('settings-web-enabled').checked = config.web_enabled === true;
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load SLMM config:', error);
|
||||||
|
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettingsModal() {
|
||||||
|
document.getElementById('settings-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('settings-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const unitId = document.getElementById('settings-unit-id').value;
|
||||||
|
const errorDiv = document.getElementById('settings-error');
|
||||||
|
const successDiv = document.getElementById('settings-success');
|
||||||
|
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
successDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
// Gather form data
|
||||||
|
const configData = {
|
||||||
|
host: document.getElementById('settings-host').value.trim(),
|
||||||
|
tcp_port: parseInt(document.getElementById('settings-tcp-port').value),
|
||||||
|
ftp_port: parseInt(document.getElementById('settings-ftp-port').value),
|
||||||
|
ftp_username: document.getElementById('settings-ftp-username').value.trim() || null,
|
||||||
|
ftp_password: document.getElementById('settings-ftp-password').value || null,
|
||||||
|
tcp_enabled: document.getElementById('settings-tcp-enabled').checked,
|
||||||
|
ftp_enabled: document.getElementById('settings-ftp-enabled').checked,
|
||||||
|
web_enabled: document.getElementById('settings-web-enabled').checked
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!configData.host) {
|
||||||
|
errorDiv.textContent = 'Host/IP address is required';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configData.tcp_port < 1 || configData.tcp_port > 65535) {
|
||||||
|
errorDiv.textContent = 'TCP port must be between 1 and 65535';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configData.ftp_port < 1 || configData.ftp_port > 65535) {
|
||||||
|
errorDiv.textContent = 'FTP port must be between 1 and 65535';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(configData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to update configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
successDiv.textContent = 'Configuration saved successfully!';
|
||||||
|
successDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Close modal after 1.5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
closeSettingsModal();
|
||||||
|
// Optionally reload the page to reflect changes
|
||||||
|
// window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
} catch (error) {
|
||||||
|
errorDiv.textContent = error.message;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// FTP Browser Modal
|
||||||
|
// ========================================
|
||||||
|
async function openFTPBrowser(unitId) {
|
||||||
|
const modal = document.getElementById('ftp-modal');
|
||||||
|
document.getElementById('ftp-unit-id').value = unitId;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Check FTP status and update UI
|
||||||
|
await updateFTPStatus(unitId);
|
||||||
|
loadFTPFiles(unitId, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateFTPStatus(unitId) {
|
||||||
|
const statusBadge = document.getElementById('ftp-status-badge');
|
||||||
|
|
||||||
|
// Show checking state
|
||||||
|
statusBadge.innerHTML = '<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-gray-500 mr-2"></div>Checking...';
|
||||||
|
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/ftp/status`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'ok' && result.ftp_enabled) {
|
||||||
|
statusBadge.innerHTML = '<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>FTP Enabled';
|
||||||
|
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400';
|
||||||
|
} else {
|
||||||
|
statusBadge.innerHTML = '<span class="w-2 h-2 bg-amber-500 rounded-full mr-2"></span>FTP Disabled';
|
||||||
|
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-400';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
statusBadge.innerHTML = '<span class="w-2 h-2 bg-gray-500 rounded-full mr-2"></span>Status Unknown';
|
||||||
|
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFTPBrowser() {
|
||||||
|
document.getElementById('ftp-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFTPFiles(unitId, path) {
|
||||||
|
const container = document.getElementById('ftp-files-list');
|
||||||
|
const pathDisplay = document.getElementById('ftp-current-path');
|
||||||
|
const errorDiv = document.getElementById('ftp-error');
|
||||||
|
|
||||||
|
// Update path display
|
||||||
|
pathDisplay.textContent = path || '/';
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
container.innerHTML = '<div class="text-center py-8 text-gray-500"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-2"></div>Loading files...</div>';
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=${encodeURIComponent(path)}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.status === 502) {
|
||||||
|
// FTP connection failed - likely not enabled or network issue
|
||||||
|
const detail = result.detail || 'Connection failed';
|
||||||
|
errorDiv.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium">FTP Connection Failed</p>
|
||||||
|
<p class="text-sm mt-1">${detail}</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="enableFTP('${unitId}')"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-orange-600 transition-colors whitespace-nowrap">
|
||||||
|
Enable FTP
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs bg-blue-50 dark:bg-blue-900/20 p-3 rounded">
|
||||||
|
<p class="font-medium text-blue-800 dark:text-blue-400 mb-1">Troubleshooting:</p>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-blue-700 dark:text-blue-300">
|
||||||
|
<li>Click "Enable FTP" to activate FTP on the device</li>
|
||||||
|
<li>Ensure the device is powered on and connected to the network</li>
|
||||||
|
<li>Check that port 21 (FTP) is not blocked by firewalls</li>
|
||||||
|
<li>Verify the modem/IP address is correct in unit settings</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 'ok') {
|
||||||
|
throw new Error(result.detail || 'Failed to list files');
|
||||||
|
}
|
||||||
|
|
||||||
|
// SLMM returns 'files' not 'data'
|
||||||
|
const files = result.files || result.data || [];
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-center py-8 text-gray-500">No files found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: directories first, then by name
|
||||||
|
files.sort((a, b) => {
|
||||||
|
if (a.type === 'directory' && b.type !== 'directory') return -1;
|
||||||
|
if (a.type !== 'directory' && b.type === 'directory') return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = '<div class="space-y-1">';
|
||||||
|
|
||||||
|
// Add parent directory link if not at root
|
||||||
|
if (path && path !== '/') {
|
||||||
|
const parentPath = path.split('/').slice(0, -1).join('/') || '/';
|
||||||
|
html += `
|
||||||
|
<div class="flex items-center p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer" onclick="loadFTPFiles('${unitId}', '${parentPath}')">
|
||||||
|
<svg class="w-5 h-5 mr-3 text-gray-400" 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"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">..</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add files and directories
|
||||||
|
files.forEach(file => {
|
||||||
|
const fullPath = file.path || (path === '/' ? `/${file.name}` : `${path}/${file.name}`);
|
||||||
|
const isDir = file.is_dir || file.type === 'directory';
|
||||||
|
|
||||||
|
// Determine file type icon and color
|
||||||
|
let icon, iconColor = 'text-gray-400';
|
||||||
|
if (isDir) {
|
||||||
|
icon = '<svg class="w-5 h-5 mr-3 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>';
|
||||||
|
} else if (file.name.toLowerCase().endsWith('.csv')) {
|
||||||
|
icon = '<svg class="w-5 h-5 mr-3 text-green-500" 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"></path></svg>';
|
||||||
|
} else if (file.name.toLowerCase().match(/\.(txt|log)$/)) {
|
||||||
|
icon = '<svg class="w-5 h-5 mr-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"></path></svg>';
|
||||||
|
} else {
|
||||||
|
icon = '<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeText = file.size ? formatFileSize(file.size) : '';
|
||||||
|
const dateText = file.modified || file.modified_time || '';
|
||||||
|
const canPreview = !isDir && (file.name.toLowerCase().match(/\.(csv|txt|log)$/));
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
html += `
|
||||||
|
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer transition-colors" onclick="loadFTPFiles('${unitId}', '${escapeForAttribute(fullPath)}')">
|
||||||
|
<div class="flex items-center flex-1">
|
||||||
|
${icon}
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500">${dateText}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
html += `
|
||||||
|
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors group">
|
||||||
|
<div class="flex items-center flex-1 min-w-0">
|
||||||
|
${icon}
|
||||||
|
<span class="text-gray-900 dark:text-white truncate">${escapeHtml(file.name)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 flex-shrink-0 ml-4">
|
||||||
|
<span class="text-xs text-gray-500 hidden sm:inline">${sizeText}</span>
|
||||||
|
<span class="text-xs text-gray-500 hidden md:inline">${dateText}</span>
|
||||||
|
${canPreview ? `
|
||||||
|
<button onclick="previewFile('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
|
||||||
|
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors flex items-center"
|
||||||
|
title="Preview file">
|
||||||
|
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<button onclick="downloadFTPFile('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
|
||||||
|
class="px-3 py-1 bg-seismo-orange hover:bg-orange-600 text-white text-sm rounded transition-colors flex items-center"
|
||||||
|
title="Download to your computer">
|
||||||
|
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden lg:inline">Download</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeForAttribute(str) {
|
||||||
|
return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load FTP files:', error);
|
||||||
|
errorDiv.textContent = error.message;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFTPFile(unitId, filePath, fileName) {
|
||||||
|
try {
|
||||||
|
// Show download indicator
|
||||||
|
const downloadBtn = event.target;
|
||||||
|
const originalText = downloadBtn.innerHTML;
|
||||||
|
downloadBtn.innerHTML = '<svg class="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>';
|
||||||
|
downloadBtn.disabled = true;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ remote_path: filePath })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Download failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The response is a file, so we need to create a download link
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName || filePath.split('/').pop();
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
downloadBtn.innerHTML = originalText;
|
||||||
|
downloadBtn.disabled = false;
|
||||||
|
|
||||||
|
// Show success message briefly
|
||||||
|
const originalBtnClass = downloadBtn.className;
|
||||||
|
downloadBtn.className = downloadBtn.className.replace('bg-seismo-orange', 'bg-green-600');
|
||||||
|
setTimeout(() => {
|
||||||
|
downloadBtn.className = originalBtnClass;
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download file:', error);
|
||||||
|
alert('Failed to download file: ' + error.message);
|
||||||
|
// Reset button on error
|
||||||
|
if (event.target) {
|
||||||
|
event.target.innerHTML = originalText;
|
||||||
|
event.target.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadToServer(unitId, filePath, fileName) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ remote_path: filePath, save_to_server: true })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
alert(`File saved to server at: ${result.local_path}`);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.detail || 'Failed to save to server');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save file to server:', error);
|
||||||
|
alert('Failed to save file to server: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewFile(unitId, filePath, fileName) {
|
||||||
|
const modal = document.getElementById('preview-modal');
|
||||||
|
const previewContent = document.getElementById('preview-content');
|
||||||
|
const previewTitle = document.getElementById('preview-title');
|
||||||
|
|
||||||
|
previewTitle.textContent = fileName;
|
||||||
|
previewContent.innerHTML = '<div class="text-center py-8"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-2"></div>Loading preview...</div>';
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ remote_path: filePath })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const text = await blob.text();
|
||||||
|
|
||||||
|
// Check file type for syntax highlighting
|
||||||
|
const isCSV = fileName.toLowerCase().endsWith('.csv');
|
||||||
|
const isTXT = fileName.toLowerCase().endsWith('.txt') || fileName.toLowerCase().endsWith('.log');
|
||||||
|
|
||||||
|
if (isCSV) {
|
||||||
|
// Parse and display CSV as table
|
||||||
|
const lines = text.split('\n').filter(l => l.trim());
|
||||||
|
if (lines.length > 0) {
|
||||||
|
const headers = lines[0].split(',');
|
||||||
|
let tableHTML = '<div class="overflow-x-auto"><table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700"><thead class="bg-gray-50 dark:bg-gray-800"><tr>';
|
||||||
|
headers.forEach(h => {
|
||||||
|
tableHTML += `<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">${h.trim()}</th>`;
|
||||||
|
});
|
||||||
|
tableHTML += '</tr></thead><tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">';
|
||||||
|
|
||||||
|
for (let i = 1; i < Math.min(lines.length, 101); i++) {
|
||||||
|
const cells = lines[i].split(',');
|
||||||
|
tableHTML += '<tr>';
|
||||||
|
cells.forEach(c => {
|
||||||
|
tableHTML += `<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">${c.trim()}</td>`;
|
||||||
|
});
|
||||||
|
tableHTML += '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
tableHTML += '</tbody></table></div>';
|
||||||
|
if (lines.length > 101) {
|
||||||
|
tableHTML += `<p class="text-sm text-gray-500 mt-4 text-center">Showing first 100 rows of ${lines.length - 1} total rows</p>`;
|
||||||
|
}
|
||||||
|
previewContent.innerHTML = tableHTML;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Display as plain text with line numbers
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const maxLines = 1000;
|
||||||
|
const displayLines = lines.slice(0, maxLines);
|
||||||
|
|
||||||
|
let preHTML = '<pre class="text-xs font-mono bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto"><code>';
|
||||||
|
displayLines.forEach((line, i) => {
|
||||||
|
preHTML += `<span class="text-gray-500">${String(i + 1).padStart(4, ' ')}</span> ${escapeHtml(line)}\n`;
|
||||||
|
});
|
||||||
|
preHTML += '</code></pre>';
|
||||||
|
|
||||||
|
if (lines.length > maxLines) {
|
||||||
|
preHTML += `<p class="text-sm text-gray-500 mt-4 text-center">Showing first ${maxLines} lines of ${lines.length} total lines</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewContent.innerHTML = preHTML;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to preview file:', error);
|
||||||
|
previewContent.innerHTML = `<div class="text-center py-8 text-red-500">Failed to load preview: ${error.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePreviewModal() {
|
||||||
|
document.getElementById('preview-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableFTP(unitId) {
|
||||||
|
const errorDiv = document.getElementById('ftp-error');
|
||||||
|
const container = document.getElementById('ftp-files-list');
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
errorDiv.innerHTML = '<div class="flex items-center"><div class="animate-spin rounded-full h-5 w-5 border-b-2 border-seismo-orange mr-3"></div>Enabling FTP on device...</div>';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/ftp/enable`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status !== 'ok') {
|
||||||
|
throw new Error(result.detail || 'Failed to enable FTP');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - wait a moment then try loading files again
|
||||||
|
errorDiv.innerHTML = '<div class="flex items-center text-green-600 dark:text-green-400"><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="M5 13l4 4L19 7"></path></svg>FTP enabled successfully. Loading files...</div>';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
loadFTPFiles(unitId, '/');
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to enable FTP:', error);
|
||||||
|
errorDiv.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-red-600 dark:text-red-400">Failed to enable FTP</p>
|
||||||
|
<p class="text-sm mt-1">${error.message}</p>
|
||||||
|
<button onclick="loadFTPFiles('${unitId}', '/')"
|
||||||
|
class="mt-2 px-3 py-1 bg-gray-600 text-white text-sm rounded hover:bg-gray-700 transition-colors">
|
||||||
|
Retry Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableFTPFromHeader() {
|
||||||
|
const unitId = document.getElementById('ftp-unit-id').value;
|
||||||
|
const statusBadge = document.getElementById('ftp-status-badge');
|
||||||
|
const errorDiv = document.getElementById('ftp-error');
|
||||||
|
|
||||||
|
// Show enabling state
|
||||||
|
statusBadge.innerHTML = '<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-seismo-orange mr-2"></div>Enabling...';
|
||||||
|
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400';
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/ftp/enable`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status !== 'ok') {
|
||||||
|
throw new Error(result.detail || 'Failed to enable FTP');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status badge
|
||||||
|
statusBadge.innerHTML = '<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>FTP Enabled';
|
||||||
|
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400';
|
||||||
|
|
||||||
|
// Show success message and refresh files
|
||||||
|
errorDiv.innerHTML = '<div class="flex items-center text-green-600 dark:text-green-400"><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="M5 13l4 4L19 7"></path></svg>FTP enabled successfully</div>';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
loadFTPFiles(unitId, '/');
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to enable FTP:', error);
|
||||||
|
statusBadge.innerHTML = '<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>Enable Failed';
|
||||||
|
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400';
|
||||||
|
|
||||||
|
errorDiv.innerHTML = `<p class="font-medium">Failed to enable FTP:</p><p class="text-sm mt-1">${error.message}</p>`;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableFTPFromHeader() {
|
||||||
|
const unitId = document.getElementById('ftp-unit-id').value;
|
||||||
|
const statusBadge = document.getElementById('ftp-status-badge');
|
||||||
|
const errorDiv = document.getElementById('ftp-error');
|
||||||
|
|
||||||
|
// Show disabling state
|
||||||
|
statusBadge.innerHTML = '<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-amber-500 mr-2"></div>Disabling...';
|
||||||
|
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-400';
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/ftp/disable`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status !== 'ok') {
|
||||||
|
throw new Error(result.detail || 'Failed to disable FTP');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status badge
|
||||||
|
statusBadge.innerHTML = '<span class="w-2 h-2 bg-amber-500 rounded-full mr-2"></span>FTP Disabled';
|
||||||
|
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-400';
|
||||||
|
|
||||||
|
// Show success message and clear files
|
||||||
|
errorDiv.innerHTML = '<div class="flex items-center text-amber-600 dark:text-amber-400"><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="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>FTP disabled successfully</div>';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
document.getElementById('ftp-files-list').innerHTML = '<div class="text-center py-8 text-gray-500">FTP is disabled. Enable it to browse files.</div>';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disable FTP:', error);
|
||||||
|
statusBadge.innerHTML = '<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>Disable Failed';
|
||||||
|
statusBadge.className = 'flex items-center text-xs px-3 py-1 rounded bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400';
|
||||||
|
|
||||||
|
errorDiv.innerHTML = `<p class="font-medium">Failed to disable FTP:</p><p class="text-sm mt-1">${error.message}</p>`;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFTPFiles() {
|
||||||
|
const unitId = document.getElementById('ftp-unit-id').value;
|
||||||
|
const currentPath = document.getElementById('ftp-current-path').textContent;
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
updateFTPStatus(unitId);
|
||||||
|
|
||||||
|
// Reload files at current path
|
||||||
|
loadFTPFiles(unitId, currentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modals on Escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeSettingsModal();
|
||||||
|
closeFTPBrowser();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modals when clicking outside
|
||||||
|
document.getElementById('settings-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeSettingsModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('ftp-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeFTPBrowser();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('preview-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closePreviewModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
<div id="settings-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center overflow-y-auto">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl m-4 my-8">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white">SLM Configuration</h3>
|
||||||
|
<button onclick="closeSettingsModal()" 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>
|
||||||
|
|
||||||
|
<form id="settings-form" class="p-6 space-y-6">
|
||||||
|
<input type="hidden" id="settings-unit-id">
|
||||||
|
|
||||||
|
<!-- Network Configuration -->
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Network Configuration</h4>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Host / IP Address</label>
|
||||||
|
<input type="text" id="settings-host"
|
||||||
|
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"
|
||||||
|
placeholder="e.g., 192.168.1.100" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||||
|
<input type="number" id="settings-tcp-port"
|
||||||
|
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"
|
||||||
|
placeholder="2255" min="1" max="65535" required>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Default: 2255 for NL-43/NL-53</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" id="settings-ftp-port"
|
||||||
|
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"
|
||||||
|
placeholder="21" min="1" max="65535" required>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Standard FTP port (default: 21)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FTP Credentials -->
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">FTP Credentials</h4>
|
||||||
|
|
||||||
|
<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">Username</label>
|
||||||
|
<input type="text" id="settings-ftp-username"
|
||||||
|
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"
|
||||||
|
placeholder="anonymous">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Password</label>
|
||||||
|
<input type="password" id="settings-ftp-password"
|
||||||
|
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"
|
||||||
|
placeholder="••••••••">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Protocol Toggles -->
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Protocol Settings</h4>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">TCP Communication</span>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable TCP control commands</p>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" id="settings-tcp-enabled"
|
||||||
|
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">FTP File Transfer</span>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable FTP file browsing and downloads</p>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" id="settings-ftp-enabled"
|
||||||
|
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">Web Interface</span>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable web UI access (future feature)</p>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" id="settings-web-enabled"
|
||||||
|
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settings-error" class="hidden text-sm p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg"></div>
|
||||||
|
<div id="settings-success" class="hidden text-sm p-3 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-lg"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button type="button" onclick="closeSettingsModal()"
|
||||||
|
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FTP Browser Modal -->
|
||||||
|
<div id="ftp-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden m-4 flex flex-col">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white">FTP File Browser</h3>
|
||||||
|
<button onclick="closeFTPBrowser()" 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 class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Path: <span id="ftp-current-path" class="font-mono">/</span>
|
||||||
|
</p>
|
||||||
|
<div id="ftp-status-badge" class="flex items-center text-xs px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||||
|
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-gray-500 mr-2"></div>
|
||||||
|
Checking...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="ftp-enable-btn" onclick="enableFTPFromHeader()"
|
||||||
|
class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg flex items-center transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-1.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>
|
||||||
|
Enable FTP
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="ftp-disable-btn" onclick="disableFTPFromHeader()"
|
||||||
|
class="px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white text-sm rounded-lg flex items-center transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-1.5" 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>
|
||||||
|
Disable FTP
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="ftp-refresh-btn" onclick="refreshFTPFiles()"
|
||||||
|
class="px-3 py-1.5 bg-seismo-orange hover:bg-orange-600 text-white text-sm rounded-lg flex items-center transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-1.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>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
|
<input type="hidden" id="ftp-unit-id">
|
||||||
|
|
||||||
|
<div id="ftp-error" class="hidden mb-4 p-4 bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400 rounded-lg text-sm"></div>
|
||||||
|
|
||||||
|
<div id="ftp-files-list">
|
||||||
|
<!-- Files will be loaded here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Preview Modal -->
|
||||||
|
<div id="preview-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-shrink-0">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<svg class="w-6 h-6 mr-2 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="preview-title">File Preview</span>
|
||||||
|
</h3>
|
||||||
|
<button onclick="closePreviewModal()" 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 class="flex-1 overflow-y-auto p-6 bg-gray-50 dark:bg-gray-900" id="preview-content">
|
||||||
|
<!-- Preview content will be loaded here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -372,6 +372,12 @@
|
|||||||
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||||
Save Changes
|
Save Changes
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onclick="openRenameUnitModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
|
||||||
|
</svg>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
<button type="button" onclick="closeEditUnitModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg transition-colors">
|
<button type="button" onclick="closeEditUnitModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg transition-colors">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -380,6 +386,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Rename Unit Modal -->
|
||||||
|
<div id="renameUnitModal" 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 shadow-2xl max-w-md w-full mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Rename Unit</h2>
|
||||||
|
<button onclick="closeRenameUnitModal()" class="text-gray-400 hover:text-gray-600 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>
|
||||||
|
</div>
|
||||||
|
<form id="renameUnitForm" class="p-6 space-y-4">
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<svg class="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" 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>
|
||||||
|
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important: Renaming Changes All References</p>
|
||||||
|
<p class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
|
||||||
|
This will update the unit ID everywhere including history, assignments, and sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Current Unit ID</label>
|
||||||
|
<input type="text" id="renameOldId" readonly
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed font-mono">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">New Unit ID *</label>
|
||||||
|
<input type="text" id="renameNewId" required pattern="[^\s]+" title="Unit ID cannot contain spaces"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 font-mono"
|
||||||
|
placeholder="Enter new unit ID (no spaces)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button type="submit" class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium">
|
||||||
|
Rename Unit
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="closeRenameUnitModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Import CSV Modal -->
|
<!-- Import CSV Modal -->
|
||||||
<div id="importModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div id="importModal" 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 shadow-2xl max-w-lg w-full mx-4">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full mx-4">
|
||||||
@@ -1077,6 +1136,76 @@
|
|||||||
function filterRosterTable() {
|
function filterRosterTable() {
|
||||||
filterDevices();
|
filterDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rename Unit Modal Functions
|
||||||
|
function openRenameUnitModal() {
|
||||||
|
const currentUnitId = document.getElementById('editUnitId').value;
|
||||||
|
document.getElementById('renameOldId').value = currentUnitId;
|
||||||
|
document.getElementById('renameNewId').value = '';
|
||||||
|
document.getElementById('renameUnitModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRenameUnitModal() {
|
||||||
|
document.getElementById('renameUnitModal').classList.add('hidden');
|
||||||
|
document.getElementById('renameUnitForm').reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Rename Unit form submission
|
||||||
|
document.getElementById('renameUnitForm').addEventListener('submit', async function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const oldId = document.getElementById('renameOldId').value;
|
||||||
|
const newId = document.getElementById('renameNewId').value.trim();
|
||||||
|
|
||||||
|
if (!newId) {
|
||||||
|
alert('Please enter a new unit ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldId === newId) {
|
||||||
|
alert('New unit ID must be different from the current ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final confirmation
|
||||||
|
const confirmed = confirm(
|
||||||
|
`Are you sure you want to rename '${oldId}' to '${newId}'?\n\n` +
|
||||||
|
`This will update:\n` +
|
||||||
|
`• Unit roster entry\n` +
|
||||||
|
`• All history records\n` +
|
||||||
|
`• Project assignments\n` +
|
||||||
|
`• Recording sessions\n` +
|
||||||
|
`• Modem references\n\n` +
|
||||||
|
`This action cannot be undone.`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('old_id', oldId);
|
||||||
|
formData.append('new_id', newId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/roster/rename', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.success) {
|
||||||
|
alert(`✓ Successfully renamed unit from '${oldId}' to '${newId}'`);
|
||||||
|
closeRenameUnitModal();
|
||||||
|
closeEditUnitModal();
|
||||||
|
// Reload the page to show updated unit ID
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert(`Error: ${result.detail || result.message || 'Failed to rename unit'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error renaming unit: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -18,13 +18,61 @@
|
|||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/roster" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
<a href="#" onclick="goBack(event)" 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">
|
<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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Back to Roster
|
<span id="back-link-text">Back to Sound Level Meters</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function goBack(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Check if there's a previous page in history
|
||||||
|
// and it's from the same site (not external)
|
||||||
|
if (window.history.length > 1 && document.referrer) {
|
||||||
|
const referrer = new URL(document.referrer);
|
||||||
|
const current = new URL(window.location.href);
|
||||||
|
|
||||||
|
// If referrer is from the same origin, go back
|
||||||
|
if (referrer.origin === current.origin) {
|
||||||
|
window.history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, go to SLM dashboard
|
||||||
|
window.location.href = '/sound-level-meters';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the back link text based on referrer
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const backText = document.getElementById('back-link-text');
|
||||||
|
if (backText && document.referrer) {
|
||||||
|
try {
|
||||||
|
const referrer = new URL(document.referrer);
|
||||||
|
const current = new URL(window.location.href);
|
||||||
|
|
||||||
|
// Only update if from same origin
|
||||||
|
if (referrer.origin === current.origin) {
|
||||||
|
if (referrer.pathname.includes('/sound-level-meters')) {
|
||||||
|
backText.textContent = 'Back to Sound Level Meters';
|
||||||
|
} else if (referrer.pathname.includes('/roster')) {
|
||||||
|
backText.textContent = 'Back to Roster';
|
||||||
|
} else if (referrer.pathname.includes('/projects')) {
|
||||||
|
backText.textContent = 'Back to Projects';
|
||||||
|
} else if (referrer.pathname === '/') {
|
||||||
|
backText.textContent = 'Back to Dashboard';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid referrer, keep default text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
@@ -52,159 +100,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Control Panel -->
|
<!-- Command Center -->
|
||||||
<div class="mb-8">
|
<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">Control Panel</h2>
|
<div id="slm-command-center"
|
||||||
<div hx-get="/slm/partials/{{ unit_id }}/controls"
|
hx-get="/api/slm-dashboard/live-view/{{ unit_id }}"
|
||||||
hx-trigger="load, every 5s"
|
hx-trigger="load"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="text-center py-8 text-gray-500">Loading controls...</div>
|
<div class="text-center py-8 text-gray-500">
|
||||||
</div>
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-seismo-orange mx-auto mb-4"></div>
|
||||||
</div>
|
<p>Loading command center...</p>
|
||||||
|
|
||||||
<!-- 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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,7 +6,30 @@
|
|||||||
<!-- Breadcrumb Navigation -->
|
<!-- Breadcrumb Navigation -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<nav class="flex items-center space-x-2 text-sm">
|
<nav class="flex items-center space-x-2 text-sm">
|
||||||
{% if from_project and project %}
|
{% if from_nrl and nrl_location and from_project and project %}
|
||||||
|
<!-- From NRL Location: Projects > Project > NRL > Unit -->
|
||||||
|
<a href="/projects" class="text-gray-500 hover:text-seismo-orange">Projects</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" 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 href="/projects/{{ from_project }}" class="text-gray-500 hover:text-seismo-orange">
|
||||||
|
{{ project.name }}
|
||||||
|
</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" 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 href="/projects/{{ from_project }}/nrl/{{ from_nrl }}" class="text-seismo-orange hover:text-seismo-navy 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>
|
||||||
|
{{ nrl_location.name }}
|
||||||
|
</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" 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>
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">{{ unit_id }}</span>
|
||||||
|
{% elif from_project and project %}
|
||||||
|
<!-- From Project: Projects > Project > Unit -->
|
||||||
<a href="/projects" class="text-gray-500 hover:text-seismo-orange">Projects</a>
|
<a href="/projects" class="text-gray-500 hover:text-seismo-orange">Projects</a>
|
||||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 text-gray-400" 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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
@@ -22,6 +45,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="text-gray-900 dark:text-white font-medium">{{ unit_id }}</span>
|
<span class="text-gray-900 dark:text-white font-medium">{{ unit_id }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<!-- Default: Sound Level Meters > Unit -->
|
||||||
<a href="/sound-level-meters" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
<a href="/sound-level-meters" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
@@ -47,9 +71,11 @@
|
|||||||
{{ unit_id }}
|
{{ unit_id }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
Sound Level Meter Control Center
|
Sound Level Meter {% if from_project or from_nrl %}Operations{% else %}Control Center{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{% if not from_project and not from_nrl %}
|
||||||
|
<!-- Configure button only shown in administrative context (accessed from roster/SLM dashboard) -->
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button onclick="openConfigModal()"
|
<button onclick="openConfigModal()"
|
||||||
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center">
|
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center">
|
||||||
@@ -60,6 +86,7 @@
|
|||||||
Configure
|
Configure
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,13 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sound Level Meters</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Monitor and manage sound level measurement devices</p>
|
<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="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
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Monitor and control sound level measurement devices</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
@@ -20,17 +25,107 @@
|
|||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Grid -->
|
<!-- Device List with Quick Actions -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
|
||||||
<!-- Projects Card -->
|
<div class="flex items-center justify-between mb-6">
|
||||||
<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">Active Devices</h2>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">Auto-refresh: 15s</span>
|
||||||
|
<a href="/roster" class="text-sm text-seismo-orange hover:text-seismo-navy">Manage roster</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="slm-devices-list"
|
||||||
|
class="space-y-3"
|
||||||
|
hx-get="/api/slm-dashboard/units?include_measurement=true"
|
||||||
|
hx-trigger="load, every 15s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="animate-pulse space-y-3">
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-32 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-32 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-32 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Measurement Chart - shows when a device is selected -->
|
||||||
|
<div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Live Measurements</h2>
|
||||||
|
<button onclick="closeLiveChart()" 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>
|
||||||
|
|
||||||
|
<!-- 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="chart-lp" class="text-2xl font-bold text-blue-600 dark:text-blue-400">--</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</p>
|
||||||
|
<p id="chart-leq" class="text-2xl font-bold text-green-600 dark:text-green-400">--</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmax (Max)</p>
|
||||||
|
<p id="chart-lmax" class="text-2xl font-bold text-red-600 dark:text-red-400">--</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
|
||||||
|
<p id="chart-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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="chart-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4" style="min-height: 400px;">
|
||||||
|
<canvas id="dashboardLiveChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stream Control -->
|
||||||
|
<div class="mt-4 flex justify-center gap-3">
|
||||||
|
<button id="start-chart-stream" onclick="startDashboardStream()"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-orange-600 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="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
Start Live Stream
|
||||||
|
</button>
|
||||||
|
<button id="stop-chart-stream" onclick="stopDashboardStream()" style="display: none;"
|
||||||
|
class="px-6 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 Live Stream
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects Overview -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Projects</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Active Projects</h2>
|
||||||
<a href="/projects" class="text-sm text-seismo-orange hover:text-seismo-navy">View all</a>
|
<a href="/projects" class="text-sm text-seismo-orange hover:text-seismo-navy">View all</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="slm-projects-list"
|
<div id="slm-projects-list"
|
||||||
class="space-y-3 max-h-[600px] overflow-y-auto"
|
class="space-y-3 max-h-[400px] overflow-y-auto"
|
||||||
hx-get="/api/projects/list?status=active&project_type_id=sound_monitoring&view=compact"
|
hx-get="/api/projects/list?status=active&project_type_id=sound_monitoring&view=compact"
|
||||||
hx-trigger="load, every 60s"
|
hx-trigger="load, every 60s"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
@@ -40,27 +135,6 @@
|
|||||||
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Devices Card -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Devices</h2>
|
|
||||||
<a href="/roster" class="text-sm text-seismo-orange hover:text-seismo-navy">Manage roster</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="slm-devices-list"
|
|
||||||
class="space-y-3 max-h-[600px] overflow-y-auto"
|
|
||||||
hx-get="/api/slm-dashboard/units?include_measurement=true"
|
|
||||||
hx-trigger="load, every 15s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<div class="animate-pulse space-y-3">
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Configuration Modal -->
|
<!-- Configuration Modal -->
|
||||||
@@ -85,7 +159,213 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
// Global variables
|
||||||
|
window.dashboardChart = null;
|
||||||
|
window.dashboardWebSocket = null;
|
||||||
|
window.selectedUnitId = null;
|
||||||
|
window.dashboardChartData = {
|
||||||
|
timestamps: [],
|
||||||
|
lp: [],
|
||||||
|
leq: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Chart.js
|
||||||
|
function initializeDashboardChart() {
|
||||||
|
if (typeof Chart === 'undefined') {
|
||||||
|
setTimeout(initializeDashboardChart, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.getElementById('dashboardLiveChart');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
if (window.dashboardChart) {
|
||||||
|
window.dashboardChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
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.dashboardChart = 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show live chart for a specific unit
|
||||||
|
function showLiveChart(unitId) {
|
||||||
|
window.selectedUnitId = unitId;
|
||||||
|
const panel = document.getElementById('live-chart-panel');
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Initialize chart if needed
|
||||||
|
if (!window.dashboardChart) {
|
||||||
|
initializeDashboardChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset data
|
||||||
|
window.dashboardChartData = {
|
||||||
|
timestamps: [],
|
||||||
|
lp: [],
|
||||||
|
leq: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll to chart
|
||||||
|
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLiveChart() {
|
||||||
|
stopDashboardStream();
|
||||||
|
document.getElementById('live-chart-panel').classList.add('hidden');
|
||||||
|
window.selectedUnitId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket streaming
|
||||||
|
function startDashboardStream() {
|
||||||
|
if (!window.selectedUnitId) return;
|
||||||
|
|
||||||
|
// Close existing connection
|
||||||
|
if (window.dashboardWebSocket) {
|
||||||
|
window.dashboardWebSocket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset chart data
|
||||||
|
window.dashboardChartData = { timestamps: [], lp: [], leq: [] };
|
||||||
|
if (window.dashboardChart) {
|
||||||
|
window.dashboardChart.data.labels = [];
|
||||||
|
window.dashboardChart.data.datasets[0].data = [];
|
||||||
|
window.dashboardChart.data.datasets[1].data = [];
|
||||||
|
window.dashboardChart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/live`;
|
||||||
|
|
||||||
|
window.dashboardWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
window.dashboardWebSocket.onopen = function() {
|
||||||
|
console.log('Dashboard WebSocket connected');
|
||||||
|
document.getElementById('start-chart-stream').style.display = 'none';
|
||||||
|
document.getElementById('stop-chart-stream').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.dashboardWebSocket.onmessage = function(event) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
updateDashboardMetrics(data);
|
||||||
|
updateDashboardChart(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.dashboardWebSocket.onerror = function(error) {
|
||||||
|
console.error('Dashboard WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.dashboardWebSocket.onclose = function() {
|
||||||
|
console.log('Dashboard WebSocket closed');
|
||||||
|
document.getElementById('start-chart-stream').style.display = 'flex';
|
||||||
|
document.getElementById('stop-chart-stream').style.display = 'none';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDashboardStream() {
|
||||||
|
if (window.dashboardWebSocket) {
|
||||||
|
window.dashboardWebSocket.close();
|
||||||
|
window.dashboardWebSocket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboardMetrics(data) {
|
||||||
|
document.getElementById('chart-lp').textContent = data.lp || '--';
|
||||||
|
document.getElementById('chart-leq').textContent = data.leq || '--';
|
||||||
|
document.getElementById('chart-lmax').textContent = data.lmax || '--';
|
||||||
|
document.getElementById('chart-lmin').textContent = data.lmin || '--';
|
||||||
|
document.getElementById('chart-lpeak').textContent = data.lpeak || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboardChart(data) {
|
||||||
|
const now = new Date();
|
||||||
|
window.dashboardChartData.timestamps.push(now.toLocaleTimeString());
|
||||||
|
window.dashboardChartData.lp.push(parseFloat(data.lp || 0));
|
||||||
|
window.dashboardChartData.leq.push(parseFloat(data.leq || 0));
|
||||||
|
|
||||||
|
// Keep only last 60 data points
|
||||||
|
if (window.dashboardChartData.timestamps.length > 60) {
|
||||||
|
window.dashboardChartData.timestamps.shift();
|
||||||
|
window.dashboardChartData.lp.shift();
|
||||||
|
window.dashboardChartData.leq.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.dashboardChart) {
|
||||||
|
window.dashboardChart.data.labels = window.dashboardChartData.timestamps;
|
||||||
|
window.dashboardChart.data.datasets[0].data = window.dashboardChartData.lp;
|
||||||
|
window.dashboardChart.data.datasets[1].data = window.dashboardChartData.leq;
|
||||||
|
window.dashboardChart.update('none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration modal
|
||||||
function openDeviceConfigModal(unitId) {
|
function openDeviceConfigModal(unitId) {
|
||||||
const modal = document.getElementById('slm-config-modal');
|
const modal = document.getElementById('slm-config-modal');
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove('hidden');
|
||||||
@@ -111,5 +391,10 @@ document.getElementById('slm-config-modal')?.addEventListener('click', function(
|
|||||||
closeDeviceConfigModal();
|
closeDeviceConfigModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cleanup on page unload
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
stopDashboardStream();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user