From be83cb3fe70a0093415a5a897ab7e9e15dea47c2 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Wed, 14 Jan 2026 01:44:30 +0000 Subject: [PATCH] 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. --- backend/main.py | 14 +- backend/routers/roster_rename.py | 139 ++++ backend/routers/units.py | 30 + docker-compose.yml | 7 +- rename_unit.py | 138 ++++ templates/nrl_detail.html | 2 +- templates/partials/slm_device_list.html | 92 +-- templates/partials/slm_live_view.html | 906 +++++++++++++++++++++++- templates/roster.html | 129 ++++ templates/slm_detail.html | 211 ++---- templates/slm_legacy_dashboard.html | 31 +- templates/sound_level_meters.html | 357 +++++++++- 12 files changed, 1807 insertions(+), 249 deletions(-) create mode 100644 backend/routers/roster_rename.py create mode 100644 rename_unit.py diff --git a/backend/main.py b/backend/main.py index be114a7..9375b60 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,7 +18,7 @@ logging.basicConfig( logger = logging.getLogger(__name__) 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.models import IgnoredUnit @@ -84,6 +84,7 @@ app.include_router(roster.router) app.include_router(units.router) app.include_router(photos.router) app.include_router(roster_edit.router) +app.include_router(roster_rename.router) app.include_router(dashboard.router) app.include_router(dashboard_tabs.router) app.include_router(activity.router) @@ -162,6 +163,7 @@ async def slm_legacy_dashboard( request: Request, unit_id: str, from_project: Optional[str] = None, + from_nrl: Optional[str] = None, db: Session = Depends(get_db) ): """Legacy SLM control center dashboard for a specific unit""" @@ -171,11 +173,19 @@ async def slm_legacy_dashboard( from backend.models import Project 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", { "request": request, "unit_id": unit_id, "from_project": from_project, - "project": project + "from_nrl": from_nrl, + "project": project, + "nrl_location": nrl_location }) diff --git a/backend/routers/roster_rename.py b/backend/routers/roster_rename.py new file mode 100644 index 0000000..bf9a14a --- /dev/null +++ b/backend/routers/roster_rename.py @@ -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)}" + ) diff --git a/backend/routers/units.py b/backend/routers/units.py index 654fa2c..31ffc07 100644 --- a/backend/routers/units.py +++ b/backend/routers/units.py @@ -5,6 +5,7 @@ from typing import Dict, Any from backend.database import get_db from backend.services.snapshot import emit_status_snapshot +from backend.models import RosterUnit 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", ""), "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 + } diff --git a/docker-compose.yml b/docker-compose.yml index 5984715..876487b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,10 +11,12 @@ services: environment: - PYTHONUNBUFFERED=1 - ENVIRONMENT=production - - SLMM_BASE_URL=http://slmm:8100 + - SLMM_BASE_URL=http://host.docker.internal:8100 restart: unless-stopped depends_on: - slmm + extra_hosts: + - "host.docker.internal:host-gateway" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8001/health"] interval: 30s @@ -50,8 +52,7 @@ services: context: ../slmm dockerfile: Dockerfile container_name: slmm - ports: - - "8100:8100" + network_mode: host volumes: - ../slmm/data:/app/data environment: diff --git a/rename_unit.py b/rename_unit.py new file mode 100644 index 0000000..915e7fd --- /dev/null +++ b/rename_unit.py @@ -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 ") + 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) diff --git a/templates/nrl_detail.html b/templates/nrl_detail.html index 1ccd8cd..5403237 100644 --- a/templates/nrl_detail.html +++ b/templates/nrl_detail.html @@ -136,7 +136,7 @@
Assigned Unit
diff --git a/templates/partials/slm_device_list.html b/templates/partials/slm_device_list.html index 0908861..9e294ce 100644 --- a/templates/partials/slm_device_list.html +++ b/templates/partials/slm_device_list.html @@ -1,51 +1,63 @@ {% if units %} {% for unit in units %} - - -
-
- {% endfor %} {% else %}
diff --git a/templates/partials/slm_live_view.html b/templates/partials/slm_live_view.html index c5ee656..50e0006 100644 --- a/templates/partials/slm_live_view.html +++ b/templates/partials/slm_live_view.html @@ -23,18 +23,40 @@ {% endif %}
- -
- {% if is_measuring %} - - - Measuring - - {% else %} - - Stopped - - {% endif %} + +
+ + + + + + + +
+ {% if is_measuring %} + + + Measuring + + {% else %} + + Stopped + + {% endif %} +
@@ -564,4 +586,864 @@ window.addEventListener('beforeunload', function() { 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 = '
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 = '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 = '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 = '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 = '
Loading files...
'; + 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 = ` +
+
+
+

FTP Connection Failed

+

${detail}

+
+ +
+
+

Troubleshooting:

+
    +
  • Click "Enable FTP" to activate FTP on the device
  • +
  • Ensure the device is powered on and connected to the network
  • +
  • Check that port 21 (FTP) is not blocked by firewalls
  • +
  • Verify the modem/IP address is correct in unit settings
  • +
+
+
+ `; + 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 = '
No files found
'; + 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 = '
'; + + // Add parent directory link if not at root + if (path && path !== '/') { + const parentPath = path.split('/').slice(0, -1).join('/') || '/'; + html += ` +
+ + + + .. +
+ `; + } + + // 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 = ''; + } else if (file.name.toLowerCase().endsWith('.csv')) { + icon = ''; + } else if (file.name.toLowerCase().match(/\.(txt|log)$/)) { + icon = ''; + } else { + icon = ''; + } + + 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 += ` +
+
+ ${icon} + ${escapeHtml(file.name)} +
+ ${dateText} +
+ `; + } else { + html += ` +
+
+ ${icon} + ${escapeHtml(file.name)} +
+
+ + + ${canPreview ? ` + + ` : ''} + +
+
+ `; + } + }); + + function escapeForAttribute(str) { + return String(str).replace(/'/g, "\\'").replace(/"/g, '"'); + } + + html += '
'; + 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 = ''; + 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 = '
Loading preview...
'; + 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 = '
'; + headers.forEach(h => { + tableHTML += ``; + }); + tableHTML += ''; + + for (let i = 1; i < Math.min(lines.length, 101); i++) { + const cells = lines[i].split(','); + tableHTML += ''; + cells.forEach(c => { + tableHTML += ``; + }); + tableHTML += ''; + } + + tableHTML += '
${h.trim()}
${c.trim()}
'; + if (lines.length > 101) { + tableHTML += `

Showing first 100 rows of ${lines.length - 1} total rows

`; + } + 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 = '
';
+            displayLines.forEach((line, i) => {
+                preHTML += `${String(i + 1).padStart(4, ' ')}  ${escapeHtml(line)}\n`;
+            });
+            preHTML += '
'; + + if (lines.length > maxLines) { + preHTML += `

Showing first ${maxLines} lines of ${lines.length} total lines

`; + } + + previewContent.innerHTML = preHTML; + } + } catch (error) { + console.error('Failed to preview file:', error); + previewContent.innerHTML = `
Failed to load preview: ${error.message}
`; + } +} + +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 = '
Enabling FTP on device...
'; + 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 = '
FTP enabled successfully. Loading files...
'; + + setTimeout(() => { + loadFTPFiles(unitId, '/'); + }, 2000); + + } catch (error) { + console.error('Failed to enable FTP:', error); + errorDiv.innerHTML = ` +
+

Failed to enable FTP

+

${error.message}

+ +
+ `; + 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 = '
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 = '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 = '
FTP enabled successfully
'; + errorDiv.classList.remove('hidden'); + + setTimeout(() => { + errorDiv.classList.add('hidden'); + loadFTPFiles(unitId, '/'); + }, 2000); + + } catch (error) { + console.error('Failed to enable FTP:', error); + statusBadge.innerHTML = '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 = `

Failed to enable FTP:

${error.message}

`; + 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 = '
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 = '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 = '
FTP disabled successfully
'; + errorDiv.classList.remove('hidden'); + + document.getElementById('ftp-files-list').innerHTML = '
FTP is disabled. Enable it to browse files.
'; + + setTimeout(() => { + errorDiv.classList.add('hidden'); + }, 3000); + + } catch (error) { + console.error('Failed to disable FTP:', error); + statusBadge.innerHTML = '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 = `

Failed to disable FTP:

${error.message}

`; + 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(); + } +}); + + + + + + + + + diff --git a/templates/roster.html b/templates/roster.html index 765e1e9..30a289f 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -372,6 +372,12 @@ + @@ -380,6 +386,59 @@
+ + +