From dc853806bb20b94ad761c1d3cf1025fe521266d0 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Wed, 3 Dec 2025 07:57:25 +0000 Subject: [PATCH] v0.2 fleet overhaul --- backend/main.py | 7 + backend/migrate_add_device_types.py | 84 ++++ backend/models.py | 18 +- backend/routers/roster_edit.py | 140 ++++++- backend/services/snapshot.py | 18 + create_test_db.py | 115 ++++++ templates/dashboard.html | 120 +++++- templates/partials/active_table.html | 73 ++-- templates/partials/benched_table.html | 73 ++-- templates/partials/roster_table.html | 92 ++++- templates/roster.html | 375 ++++++++++++++++- templates/unit_detail.html | 567 ++++++++++++++++---------- 12 files changed, 1400 insertions(+), 282 deletions(-) create mode 100644 backend/migrate_add_device_types.py create mode 100644 create_test_db.py diff --git a/backend/main.py b/backend/main.py index 3e0003a..a61f2b5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -85,6 +85,13 @@ async def roster_table_partial(request: Request): "last_seen": unit_data["last"], "deployed": unit_data["deployed"], "note": unit_data.get("note", ""), + "device_type": unit_data.get("device_type", "seismograph"), + "last_calibrated": unit_data.get("last_calibrated"), + "next_calibration_due": unit_data.get("next_calibration_due"), + "deployed_with_modem_id": unit_data.get("deployed_with_modem_id"), + "ip_address": unit_data.get("ip_address"), + "phone_number": unit_data.get("phone_number"), + "hardware_model": unit_data.get("hardware_model"), }) # Sort by status priority (Missing > Pending > OK) then by ID diff --git a/backend/migrate_add_device_types.py b/backend/migrate_add_device_types.py new file mode 100644 index 0000000..f923f34 --- /dev/null +++ b/backend/migrate_add_device_types.py @@ -0,0 +1,84 @@ +""" +Migration script to add device type support to the roster table. + +This adds columns for: +- device_type (seismograph/modem discriminator) +- Seismograph-specific fields (calibration dates, modem pairing) +- Modem-specific fields (IP address, phone number, hardware model) + +Run this script once to migrate an existing database. +""" + +import sqlite3 +import os + +# Database path +DB_PATH = "./data/seismo_fleet.db" + +def migrate_database(): + """Add new columns to the roster table""" + + if not os.path.exists(DB_PATH): + print(f"Database not found at {DB_PATH}") + print("The database will be created automatically when you run the application.") + return + + print(f"Migrating database: {DB_PATH}") + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Check if device_type column already exists + cursor.execute("PRAGMA table_info(roster)") + columns = [col[1] for col in cursor.fetchall()] + + if "device_type" in columns: + print("Migration already applied - device_type column exists") + conn.close() + return + + print("Adding new columns to roster table...") + + try: + # Add device type discriminator + cursor.execute("ALTER TABLE roster ADD COLUMN device_type TEXT DEFAULT 'seismograph'") + print(" ✓ Added device_type column") + + # Add seismograph-specific fields + cursor.execute("ALTER TABLE roster ADD COLUMN last_calibrated DATE") + print(" ✓ Added last_calibrated column") + + cursor.execute("ALTER TABLE roster ADD COLUMN next_calibration_due DATE") + print(" ✓ Added next_calibration_due column") + + cursor.execute("ALTER TABLE roster ADD COLUMN deployed_with_modem_id TEXT") + print(" ✓ Added deployed_with_modem_id column") + + # Add modem-specific fields + cursor.execute("ALTER TABLE roster ADD COLUMN ip_address TEXT") + print(" ✓ Added ip_address column") + + cursor.execute("ALTER TABLE roster ADD COLUMN phone_number TEXT") + print(" ✓ Added phone_number column") + + cursor.execute("ALTER TABLE roster ADD COLUMN hardware_model TEXT") + print(" ✓ Added hardware_model column") + + # Set all existing units to seismograph type + cursor.execute("UPDATE roster SET device_type = 'seismograph' WHERE device_type IS NULL") + print(" ✓ Set existing units to seismograph type") + + conn.commit() + print("\nMigration completed successfully!") + + except sqlite3.Error as e: + print(f"\nError during migration: {e}") + conn.rollback() + raise + + finally: + conn.close() + + +if __name__ == "__main__": + migrate_database() diff --git a/backend/models.py b/backend/models.py index 6f1f9d2..6474d80 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, DateTime, Boolean, Text +from sqlalchemy import Column, String, DateTime, Boolean, Text, Date from datetime import datetime from backend.database import Base @@ -18,11 +18,15 @@ class RosterUnit(Base): """ Roster table: represents our *intended assignment* of a unit. This is editable from the GUI. + + Supports multiple device types (seismograph, modem) with type-specific fields. """ __tablename__ = "roster" + # Core fields (all device types) id = Column(String, primary_key=True, index=True) - unit_type = Column(String, default="series3") + unit_type = Column(String, default="series3") # Backward compatibility + device_type = Column(String, default="seismograph") # "seismograph" | "modem" deployed = Column(Boolean, default=True) retired = Column(Boolean, default=False) note = Column(String, nullable=True) @@ -30,6 +34,16 @@ class RosterUnit(Base): location = Column(String, nullable=True) last_updated = Column(DateTime, default=datetime.utcnow) + # Seismograph-specific fields (nullable for modems) + last_calibrated = Column(Date, nullable=True) + next_calibration_due = Column(Date, nullable=True) + deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit + + # Modem-specific fields (nullable for seismographs) + ip_address = Column(String, nullable=True) + phone_number = Column(String, nullable=True) + hardware_model = Column(String, nullable=True) + class IgnoredUnit(Base): """ diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index 1922942..a29ab89 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File from sqlalchemy.orm import Session -from datetime import datetime +from datetime import datetime, date import csv import io @@ -23,28 +23,149 @@ def get_or_create_roster_unit(db: Session, unit_id: str): @router.post("/add") def add_roster_unit( id: str = Form(...), + device_type: str = Form("seismograph"), unit_type: str = Form("series3"), deployed: bool = Form(False), note: str = Form(""), project_id: str = Form(None), location: str = Form(None), + # Seismograph-specific fields + last_calibrated: str = Form(None), + next_calibration_due: str = Form(None), + deployed_with_modem_id: str = Form(None), + # Modem-specific fields + ip_address: str = Form(None), + phone_number: str = Form(None), + hardware_model: str = Form(None), db: Session = Depends(get_db) ): if db.query(RosterUnit).filter(RosterUnit.id == id).first(): raise HTTPException(status_code=400, detail="Unit already exists") + # Parse date fields if provided + last_cal_date = None + if last_calibrated: + try: + last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD") + + next_cal_date = None + if next_calibration_due: + try: + next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD") + unit = RosterUnit( id=id, + device_type=device_type, unit_type=unit_type, deployed=deployed, note=note, project_id=project_id, location=location, last_updated=datetime.utcnow(), + # Seismograph-specific fields + last_calibrated=last_cal_date, + next_calibration_due=next_cal_date, + deployed_with_modem_id=deployed_with_modem_id if deployed_with_modem_id else None, + # Modem-specific fields + ip_address=ip_address if ip_address else None, + phone_number=phone_number if phone_number else None, + hardware_model=hardware_model if hardware_model else None, ) db.add(unit) db.commit() - return {"message": "Unit added", "id": id} + return {"message": "Unit added", "id": id, "device_type": device_type} + + +@router.get("/{unit_id}") +def get_roster_unit(unit_id: str, db: Session = Depends(get_db)): + """Get a single roster unit by ID""" + unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + + return { + "id": unit.id, + "device_type": unit.device_type or "seismograph", + "unit_type": unit.unit_type, + "deployed": unit.deployed, + "retired": unit.retired, + "note": unit.note or "", + "project_id": unit.project_id or "", + "location": unit.location or "", + "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "", + "next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "", + "deployed_with_modem_id": unit.deployed_with_modem_id or "", + "ip_address": unit.ip_address or "", + "phone_number": unit.phone_number or "", + "hardware_model": unit.hardware_model or "", + } + + +@router.post("/edit/{unit_id}") +def edit_roster_unit( + unit_id: str, + device_type: str = Form("seismograph"), + unit_type: str = Form("series3"), + deployed: bool = Form(False), + retired: bool = Form(False), + note: str = Form(""), + project_id: str = Form(None), + location: str = Form(None), + # Seismograph-specific fields + last_calibrated: str = Form(None), + next_calibration_due: str = Form(None), + deployed_with_modem_id: str = Form(None), + # Modem-specific fields + ip_address: str = Form(None), + phone_number: str = Form(None), + hardware_model: str = Form(None), + db: Session = Depends(get_db) +): + unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + + # Parse date fields if provided + last_cal_date = None + if last_calibrated: + try: + last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD") + + next_cal_date = None + if next_calibration_due: + try: + next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD") + + # Update all fields + unit.device_type = device_type + unit.unit_type = unit_type + unit.deployed = deployed + unit.retired = retired + unit.note = note + unit.project_id = project_id + unit.location = location + unit.last_updated = datetime.utcnow() + + # Seismograph-specific fields + unit.last_calibrated = last_cal_date + unit.next_calibration_due = next_cal_date + unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None + + # Modem-specific fields + unit.ip_address = ip_address if ip_address else None + unit.phone_number = phone_number if phone_number else None + unit.hardware_model = hardware_model if hardware_model else None + + db.commit() + return {"message": "Unit updated", "id": unit_id, "device_type": device_type} @router.post("/set-deployed/{unit_id}") @@ -65,6 +186,21 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g return {"message": "Updated", "id": unit_id, "retired": retired} +@router.delete("/{unit_id}") +def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)): + """ + Permanently delete a unit from the roster database. + This is different from ignoring - the unit is completely removed. + """ + unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + + db.delete(unit) + db.commit() + return {"message": "Unit deleted", "id": unit_id} + + @router.post("/set-note/{unit_id}") def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)): unit = get_or_create_roster_unit(db, unit_id) diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index f71b2f5..fa10bdb 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -69,6 +69,16 @@ def emit_status_snapshot(): "deployed": r.deployed, "note": r.note or "", "retired": r.retired, + # Device type and type-specific fields + "device_type": r.device_type or "seismograph", + "last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None, + "next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None, + "deployed_with_modem_id": r.deployed_with_modem_id, + "ip_address": r.ip_address, + "phone_number": r.phone_number, + "hardware_model": r.hardware_model, + # Location for mapping + "location": r.location or "", } # --- Add unexpected emitter-only units --- @@ -84,6 +94,14 @@ def emit_status_snapshot(): "deployed": False, # default "note": "", "retired": False, + # Device type and type-specific fields (defaults for unknown units) + "device_type": "seismograph", # default + "last_calibrated": None, + "next_calibration_due": None, + "deployed_with_modem_id": None, + "ip_address": None, + "phone_number": None, + "hardware_model": None, } # Separate buckets for UI diff --git a/create_test_db.py b/create_test_db.py new file mode 100644 index 0000000..11b44fc --- /dev/null +++ b/create_test_db.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +Create a fresh test database with the new schema and some sample data. +""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from datetime import datetime, date, timedelta +from backend.models import Base, RosterUnit, Emitter + +# Create a new test database +TEST_DB_PATH = "/tmp/sfm_test.db" +engine = create_engine(f"sqlite:///{TEST_DB_PATH}", connect_args={"check_same_thread": False}) + +# Drop all tables and recreate them with the new schema +Base.metadata.drop_all(bind=engine) +Base.metadata.create_all(bind=engine) + +# Create a session +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +db = SessionLocal() + +try: + # Add some test seismographs + seismo1 = RosterUnit( + id="BE9449", + device_type="seismograph", + unit_type="series3", + deployed=True, + note="Primary field unit", + project_id="PROJ-001", + location="Site A", + last_calibrated=date(2024, 1, 15), + next_calibration_due=date(2025, 1, 15), + deployed_with_modem_id="MDM001", + last_updated=datetime.utcnow(), + ) + + seismo2 = RosterUnit( + id="BE9450", + device_type="seismograph", + unit_type="series3", + deployed=False, + note="Benched for maintenance", + project_id="PROJ-001", + location="Warehouse", + last_calibrated=date(2023, 6, 20), + next_calibration_due=date(2024, 6, 20), # Past due + last_updated=datetime.utcnow(), + ) + + # Add some test modems + modem1 = RosterUnit( + id="MDM001", + device_type="modem", + unit_type="modem", + deployed=True, + note="Paired with BE9449", + project_id="PROJ-001", + location="Site A", + ip_address="192.168.1.100", + phone_number="+1-555-0123", + hardware_model="Raven XTV", + last_updated=datetime.utcnow(), + ) + + modem2 = RosterUnit( + id="MDM002", + device_type="modem", + unit_type="modem", + deployed=False, + note="Spare modem", + project_id="PROJ-001", + location="Warehouse", + ip_address="192.168.1.101", + phone_number="+1-555-0124", + hardware_model="Raven XT", + last_updated=datetime.utcnow(), + ) + + # Add test emitters (status reports) + emitter1 = Emitter( + id="BE9449", + unit_type="series3", + last_seen=datetime.utcnow() - timedelta(hours=2), + last_file="BE9449.2024.336.12.00.mseed", + status="OK", + notes="Running normally", + ) + + emitter2 = Emitter( + id="BE9450", + unit_type="series3", + last_seen=datetime.utcnow() - timedelta(days=30), + last_file="BE9450.2024.306.08.00.mseed", + status="Missing", + notes="No data received", + ) + + # Add all units + db.add_all([seismo1, seismo2, modem1, modem2, emitter1, emitter2]) + db.commit() + + print(f"✓ Test database created at {TEST_DB_PATH}") + print(f"✓ Added 2 seismographs (BE9449, BE9450)") + print(f"✓ Added 2 modems (MDM001, MDM002)") + print(f"✓ Added 2 emitter status reports") + print(f"\nDatabase is ready for testing!") + +except Exception as e: + print(f"Error creating test database: {e}") + db.rollback() + raise +finally: + db.close() diff --git a/templates/dashboard.html b/templates/dashboard.html index a52a101..4e3308a 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -98,6 +98,15 @@ + +
+
+

Fleet Map

+ Deployed units +
+
+
+
@@ -132,7 +141,10 @@
-
+

Loading fleet data...

@@ -204,10 +216,116 @@ function updateDashboard(event) { alertsList.innerHTML = alertsHtml; } + // ===== Update Fleet Map ===== + updateFleetMap(data); + } catch (err) { console.error("Dashboard update error:", err); } } + +// Handle tab switching +document.addEventListener('DOMContentLoaded', function() { + const tabButtons = document.querySelectorAll('.tab-button'); + + tabButtons.forEach(button => { + button.addEventListener('click', function() { + // Remove active-tab class from all buttons + tabButtons.forEach(btn => btn.classList.remove('active-tab')); + // Add active-tab class to clicked button + this.classList.add('active-tab'); + }); + }); + + // Initialize fleet map + initFleetMap(); +}); + +let fleetMap = null; +let fleetMarkers = []; + +function initFleetMap() { + // Initialize the map centered on the US (can adjust based on your deployment area) + fleetMap = L.map('fleet-map').setView([39.8283, -98.5795], 4); + + // Add OpenStreetMap tiles + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 18 + }).addTo(fleetMap); +} + +function updateFleetMap(data) { + if (!fleetMap) return; + + // Clear existing markers + fleetMarkers.forEach(marker => fleetMap.removeLayer(marker)); + fleetMarkers = []; + + // Get deployed units with location data + const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.location); + + if (deployedUnits.length === 0) { + return; + } + + const bounds = []; + + deployedUnits.forEach(([id, unit]) => { + const coords = parseLocation(unit.location); + if (coords) { + const [lat, lon] = coords; + + // Create marker with custom color based on status + const markerColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'orange' : 'red'; + + const marker = L.circleMarker([lat, lon], { + radius: 8, + fillColor: markerColor, + color: '#fff', + weight: 2, + opacity: 1, + fillOpacity: 0.8 + }).addTo(fleetMap); + + // Add popup with unit info + marker.bindPopup(` +
+

${id}

+

Status: ${unit.status}

+

Type: ${unit.device_type}

+ ${unit.note ? `

${unit.note}

` : ''} + View Details → +
+ `); + + fleetMarkers.push(marker); + bounds.push([lat, lon]); + } + }); + + // Fit map to show all markers + if (bounds.length > 0) { + fleetMap.fitBounds(bounds, { padding: [50, 50] }); + } +} + +function parseLocation(location) { + if (!location) return null; + + // Try to parse as "lat,lon" format + const parts = location.split(',').map(s => s.trim()); + if (parts.length === 2) { + const lat = parseFloat(parts[0]); + const lon = parseFloat(parts[1]); + if (!isNaN(lat) && !isNaN(lon)) { + return [lat, lon]; + } + } + + // TODO: Add geocoding support for address strings + return null; +} {% endblock %} diff --git a/templates/partials/active_table.html b/templates/partials/active_table.html index a680d88..d9f4b72 100644 --- a/templates/partials/active_table.html +++ b/templates/partials/active_table.html @@ -1,25 +1,50 @@ - - - - - - - - - - - +{% if units %} +
+ {% for unit_id, unit in units.items() %} +
+
+ +
+ {% if unit.status == 'OK' %} + + {% elif unit.status == 'Pending' %} + + {% else %} + + {% endif %} +
-
- {% for uid, u in units.items() %} - - - - - - - - - {% endfor %} - -
IDStatusAgeLast SeenFileNote
{{ uid }}{{ u.status }}{{ u.age }}{{ u.last }}{{ u.fname }}{{ u.note }}
+ +
+
+ + {{ unit_id }} + + {% if unit.device_type == 'modem' %} + + Modem + + {% else %} + + Seismograph + + {% endif %} +
+ {% if unit.note %} +

{{ unit.note }}

+ {% endif %} +
+ + +
+ + {{ unit.age }} + +
+
+ + {% endfor %} + +{% else %} +

No active units

+{% endif %} diff --git a/templates/partials/benched_table.html b/templates/partials/benched_table.html index a680d88..b109259 100644 --- a/templates/partials/benched_table.html +++ b/templates/partials/benched_table.html @@ -1,25 +1,50 @@ - - - - - - - - - - - +{% if units %} +
+ {% for unit_id, unit in units.items() %} +
+
+ +
+ +
-
- {% for uid, u in units.items() %} - - - - - - - - - {% endfor %} - -
IDStatusAgeLast SeenFileNote
{{ uid }}{{ u.status }}{{ u.age }}{{ u.last }}{{ u.fname }}{{ u.note }}
+ +
+
+ + {{ unit_id }} + + {% if unit.device_type == 'modem' %} + + Modem + + {% else %} + + Seismograph + + {% endif %} +
+ {% if unit.note %} +

{{ unit.note }}

+ {% endif %} +
+ + +
+ {% if unit.age != 'N/A' %} + + Last seen: {{ unit.age }} ago + + {% else %} + + No data + + {% endif %} +
+ + + {% endfor %} + +{% else %} +

No benched units

+{% endif %} diff --git a/templates/partials/roster_table.html b/templates/partials/roster_table.html index 1b33fb3..f893aaf 100644 --- a/templates/partials/roster_table.html +++ b/templates/partials/roster_table.html @@ -8,6 +8,12 @@ Unit ID + + Type + + + Details + Last Seen @@ -43,7 +49,50 @@ -
{{ unit.id }}
+ + {{ unit.id }} + + + + {% if unit.device_type == 'modem' %} + + Modem + + {% else %} + + Seismograph + + {% endif %} + + +
+ {% if unit.device_type == 'modem' %} + {% if unit.ip_address %} +
{{ unit.ip_address }}
+ {% endif %} + {% if unit.phone_number %} +
{{ unit.phone_number }}
+ {% endif %} + {% if unit.hardware_model %} +
{{ unit.hardware_model }}
+ {% endif %} + {% else %} + {% if unit.next_calibration_due %} +
+ Cal Due: + {{ unit.next_calibration_due }} +
+ {% endif %} + {% if unit.deployed_with_modem_id %} +
+ Modem: + + {{ unit.deployed_with_modem_id }} + +
+ {% endif %} + {% endif %} +
{{ unit.last_seen }}
@@ -63,12 +112,41 @@ - - View - - - - +
+ + {% if unit.deployed %} + + {% else %} + + {% endif %} + + +
{% endfor %} diff --git a/templates/roster.html b/templates/roster.html index 29bc0dd..1a22ff5 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -69,6 +69,14 @@ 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-seismo-orange" placeholder="BE1234"> +
+ + +
+ + +
+

Seismograph Information

+
+ + +
+
+ + +

Typically 1 year after last calibration

+
+ +
+ + + +
@@ -111,6 +162,118 @@
+ + +