v0.4.0 - merge from claude/dev-015sto5mf2MpPCE57TbNKtaF #1

Merged
serversdown merged 32 commits from claude/dev-015sto5mf2MpPCE57TbNKtaF into main 2026-01-02 16:10:54 -05:00
12 changed files with 1400 additions and 282 deletions
Showing only changes of commit dc853806bb - Show all commits

View File

@@ -85,6 +85,13 @@ async def roster_table_partial(request: Request):
"last_seen": unit_data["last"], "last_seen": unit_data["last"],
"deployed": unit_data["deployed"], "deployed": unit_data["deployed"],
"note": unit_data.get("note", ""), "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 # Sort by status priority (Missing > Pending > OK) then by ID

View File

@@ -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()

View File

@@ -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 datetime import datetime
from backend.database import Base from backend.database import Base
@@ -18,11 +18,15 @@ class RosterUnit(Base):
""" """
Roster table: represents our *intended assignment* of a unit. Roster table: represents our *intended assignment* of a unit.
This is editable from the GUI. This is editable from the GUI.
Supports multiple device types (seismograph, modem) with type-specific fields.
""" """
__tablename__ = "roster" __tablename__ = "roster"
# Core fields (all device types)
id = Column(String, primary_key=True, index=True) 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) deployed = Column(Boolean, default=True)
retired = Column(Boolean, default=False) retired = Column(Boolean, default=False)
note = Column(String, nullable=True) note = Column(String, nullable=True)
@@ -30,6 +34,16 @@ class RosterUnit(Base):
location = Column(String, nullable=True) location = Column(String, nullable=True)
last_updated = Column(DateTime, default=datetime.utcnow) 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): class IgnoredUnit(Base):
""" """

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime, date
import csv import csv
import io import io
@@ -23,28 +23,149 @@ def get_or_create_roster_unit(db: Session, unit_id: str):
@router.post("/add") @router.post("/add")
def add_roster_unit( def add_roster_unit(
id: str = Form(...), id: str = Form(...),
device_type: str = Form("seismograph"),
unit_type: str = Form("series3"), unit_type: str = Form("series3"),
deployed: bool = Form(False), deployed: bool = Form(False),
note: str = Form(""), note: str = Form(""),
project_id: str = Form(None), project_id: str = Form(None),
location: 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) db: Session = Depends(get_db)
): ):
if db.query(RosterUnit).filter(RosterUnit.id == id).first(): if db.query(RosterUnit).filter(RosterUnit.id == id).first():
raise HTTPException(status_code=400, detail="Unit already exists") 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( unit = RosterUnit(
id=id, id=id,
device_type=device_type,
unit_type=unit_type, unit_type=unit_type,
deployed=deployed, deployed=deployed,
note=note, note=note,
project_id=project_id, project_id=project_id,
location=location, location=location,
last_updated=datetime.utcnow(), 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.add(unit)
db.commit() 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}") @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} 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}") @router.post("/set-note/{unit_id}")
def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)): def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
unit = get_or_create_roster_unit(db, unit_id) unit = get_or_create_roster_unit(db, unit_id)

View File

@@ -69,6 +69,16 @@ def emit_status_snapshot():
"deployed": r.deployed, "deployed": r.deployed,
"note": r.note or "", "note": r.note or "",
"retired": r.retired, "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 --- # --- Add unexpected emitter-only units ---
@@ -84,6 +94,14 @@ def emit_status_snapshot():
"deployed": False, # default "deployed": False, # default
"note": "", "note": "",
"retired": False, "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 # Separate buckets for UI

115
create_test_db.py Normal file
View File

@@ -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()

View File

@@ -98,6 +98,15 @@
</div> </div>
<!-- Fleet Map -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed units</span>
</div>
<div id="fleet-map" class="w-full h-96 rounded-lg"></div>
</div>
<!-- Fleet Status Section with Tabs --> <!-- Fleet Status Section with Tabs -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
@@ -132,7 +141,10 @@
</div> </div>
<!-- Tab Content Target --> <!-- Tab Content Target -->
<div id="fleet-table" class="space-y-2"> <div id="fleet-table" class="space-y-2"
hx-get="/dashboard/active"
hx-trigger="load"
hx-swap="innerHTML">
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p> <p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
</div> </div>
@@ -204,10 +216,116 @@ function updateDashboard(event) {
alertsList.innerHTML = alertsHtml; alertsList.innerHTML = alertsHtml;
} }
// ===== Update Fleet Map =====
updateFleetMap(data);
} catch (err) { } catch (err) {
console.error("Dashboard update error:", 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(`
<div class="p-2">
<h3 class="font-bold text-lg">${id}</h3>
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
<p class="text-sm">Type: ${unit.device_type}</p>
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details →</a>
</div>
`);
fleetMarkers.push(marker);
bounds.push([lat, lon]);
}
});
// Fit map to show all markers
if (bounds.length > 0) {
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;
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,25 +1,50 @@
<table class="fleet-table"> {% if units %}
<thead> <div class="space-y-2">
<tr> {% for unit_id, unit in units.items() %}
<th>ID</th> <div class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<th>Status</th> <div class="flex items-center space-x-3 flex-1">
<th>Age</th> <!-- Status Indicator -->
<th>Last Seen</th> <div class="flex-shrink-0">
<th>File</th> {% if unit.status == 'OK' %}
<th>Note</th> <span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
</tr> {% elif unit.status == 'Pending' %}
</thead> <span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
{% else %}
<span class="w-3 h-3 rounded-full bg-red-500" title="Missing"></span>
{% endif %}
</div>
<tbody> <!-- Unit Info -->
{% for uid, u in units.items() %} <div class="flex-1 min-w-0">
<tr> <div class="flex items-center gap-2">
<td>{{ uid }}</td> <a href="/unit/{{ unit_id }}" class="font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
<td>{{ u.status }}</td> {{ unit_id }}
<td>{{ u.age }}</td> </a>
<td>{{ u.last }}</td> {% if unit.device_type == 'modem' %}
<td>{{ u.fname }}</td> <span class="px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs">
<td>{{ u.note }}</td> Modem
</tr> </span>
{% else %}
<span class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs">
Seismograph
</span>
{% endif %}
</div>
{% if unit.note %}
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ unit.note }}</p>
{% endif %}
</div>
<!-- Age -->
<div class="text-right flex-shrink-0">
<span class="text-sm {% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400{% else %}text-gray-500 dark:text-gray-400{% endif %}">
{{ unit.age }}
</span>
</div>
</div>
</div>
{% endfor %} {% endfor %}
</tbody> </div>
</table> {% else %}
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-8">No active units</p>
{% endif %}

View File

@@ -1,25 +1,50 @@
<table class="fleet-table"> {% if units %}
<thead> <div class="space-y-2">
<tr> {% for unit_id, unit in units.items() %}
<th>ID</th> <div class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<th>Status</th> <div class="flex items-center space-x-3 flex-1">
<th>Age</th> <!-- Status Indicator (grayed out for benched) -->
<th>Last Seen</th> <div class="flex-shrink-0">
<th>File</th> <span class="w-3 h-3 rounded-full bg-gray-400" title="Benched"></span>
<th>Note</th> </div>
</tr>
</thead>
<tbody> <!-- Unit Info -->
{% for uid, u in units.items() %} <div class="flex-1 min-w-0">
<tr> <div class="flex items-center gap-2">
<td>{{ uid }}</td> <a href="/unit/{{ unit_id }}" class="font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
<td>{{ u.status }}</td> {{ unit_id }}
<td>{{ u.age }}</td> </a>
<td>{{ u.last }}</td> {% if unit.device_type == 'modem' %}
<td>{{ u.fname }}</td> <span class="px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs">
<td>{{ u.note }}</td> Modem
</tr> </span>
{% else %}
<span class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs">
Seismograph
</span>
{% endif %}
</div>
{% if unit.note %}
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ unit.note }}</p>
{% endif %}
</div>
<!-- Last Activity (if any) -->
<div class="text-right flex-shrink-0">
{% if unit.age != 'N/A' %}
<span class="text-sm text-gray-400 dark:text-gray-500">
Last seen: {{ unit.age }} ago
</span>
{% else %}
<span class="text-sm text-gray-400 dark:text-gray-500">
No data
</span>
{% endif %}
</div>
</div>
</div>
{% endfor %} {% endfor %}
</tbody> </div>
</table> {% else %}
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-8">No benched units</p>
{% endif %}

View File

@@ -8,6 +8,12 @@
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Unit ID Unit ID
</th> </th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Type
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Details
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Last Seen Last Seen
</th> </th>
@@ -43,7 +49,50 @@
</div> </div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ unit.id }}</div> <a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
{{ unit.id }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if unit.device_type == 'modem' %}
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
Modem
</span>
{% else %}
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
Seismograph
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
{% if unit.device_type == 'modem' %}
{% if unit.ip_address %}
<div><span class="font-mono">{{ unit.ip_address }}</span></div>
{% endif %}
{% if unit.phone_number %}
<div>{{ unit.phone_number }}</div>
{% endif %}
{% if unit.hardware_model %}
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
{% endif %}
{% else %}
{% if unit.next_calibration_due %}
<div>
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
<span class="font-medium">{{ unit.next_calibration_due }}</span>
</div>
{% endif %}
{% if unit.deployed_with_modem_id %}
<div>
<span class="text-gray-500 dark:text-gray-500">Modem:</span>
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-seismo-orange hover:underline font-medium">
{{ unit.deployed_with_modem_id }}
</a>
</div>
{% endif %}
{% endif %}
</div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div> <div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
@@ -63,12 +112,41 @@
</div> </div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="/unit/{{ unit.id }}" class="text-seismo-orange hover:text-seismo-burgundy inline-flex items-center"> <div class="flex justify-end gap-2">
View <button onclick="editUnit('{{ unit.id }}')"
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg> </svg>
</a> </button>
{% if unit.deployed %}
<button onclick="toggleDeployed('{{ unit.id }}', false)"
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-1" title="Mark as Benched">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
</button>
{% else %}
<button onclick="toggleDeployed('{{ unit.id }}', true)"
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Mark as Deployed">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</button>
{% endif %}
<button onclick="moveToIgnore('{{ unit.id }}')"
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-1" title="Move to Ignore List">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
</svg>
</button>
<button onclick="deleteUnit('{{ unit.id }}')"
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -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" 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"> placeholder="BE1234">
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Device Type *</label>
<select name="device_type" id="deviceTypeSelect" onchange="toggleDeviceFields()"
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">
<option value="seismograph">Seismograph</option>
<option value="modem">Modem</option>
</select>
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit Type</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit Type</label>
<input type="text" name="unit_type" value="series3" <input type="text" name="unit_type" value="series3"
@@ -86,9 +94,52 @@
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" 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="San Francisco, CA"> placeholder="San Francisco, CA">
</div> </div>
<!-- Seismograph-specific fields -->
<div id="seismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
<input type="date" name="last_calibrated"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
<input type="date" name="next_calibration_due"
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">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Typically 1 year after last calibration</p>
</div>
<div id="modemPairingField" class="hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
<input type="text" name="deployed_with_modem_id" placeholder="Modem ID"
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">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only needed when deployed</p>
</div>
</div>
<!-- Modem-specific fields -->
<div id="modemFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Modem Information</p>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">IP Address</label>
<input type="text" name="ip_address" placeholder="192.168.1.100"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Phone Number</label>
<input type="text" name="phone_number" placeholder="+1-555-0123"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hardware Model</label>
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV"
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">
</div>
</div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="deployed" value="true" checked <input type="checkbox" name="deployed" id="deployedCheckbox" value="true" checked onchange="toggleModemPairing()"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded"> class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span> <span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
</label> </label>
@@ -111,6 +162,118 @@
</div> </div>
</div> </div>
<!-- Edit Unit Modal -->
<div id="editUnitModal" 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 max-h-[90vh] overflow-y-auto">
<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">Edit Unit</h2>
<button onclick="closeEditUnitModal()" 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="editUnitForm" hx-post="" hx-swap="none" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit ID</label>
<input type="text" name="id" id="editUnitId" 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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Device Type *</label>
<select name="device_type" id="editDeviceTypeSelect" onchange="toggleEditDeviceFields()"
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">
<option value="seismograph">Seismograph</option>
<option value="modem">Modem</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit Type</label>
<input type="text" name="unit_type" id="editUnitType"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
<input type="text" name="project_id" id="editProjectId"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
<input type="text" name="location" id="editLocation"
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">
</div>
<!-- Seismograph-specific fields -->
<div id="editSeismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
<input type="date" name="last_calibrated" id="editLastCalibrated"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
<input type="date" name="next_calibration_due" id="editNextCalibrationDue"
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">
</div>
<div id="editModemPairingField" class="hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
<input type="text" name="deployed_with_modem_id" id="editDeployedWithModemId" placeholder="Modem ID"
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">
</div>
</div>
<!-- Modem-specific fields -->
<div id="editModemFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Modem Information</p>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">IP Address</label>
<input type="text" name="ip_address" id="editIpAddress" placeholder="192.168.1.100"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Phone Number</label>
<input type="text" name="phone_number" id="editPhoneNumber" placeholder="+1-555-0123"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hardware Model</label>
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV"
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">
</div>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="deployed" id="editDeployedCheckbox" value="true" onchange="toggleEditModemPairing()"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="retired" id="editRetiredCheckbox" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Retired</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea name="note" id="editNote" rows="3"
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"></textarea>
</div>
<div class="flex gap-3 pt-4">
<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
</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">
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">
@@ -153,6 +316,7 @@
// Add Unit Modal // Add Unit Modal
function openAddUnitModal() { function openAddUnitModal() {
document.getElementById('addUnitModal').classList.remove('hidden'); document.getElementById('addUnitModal').classList.remove('hidden');
toggleDeviceFields(); // Initialize field visibility
} }
function closeAddUnitModal() { function closeAddUnitModal() {
@@ -160,6 +324,35 @@
document.getElementById('addUnitForm').reset(); document.getElementById('addUnitForm').reset();
} }
// Toggle device-specific fields based on device type selection
function toggleDeviceFields() {
const deviceType = document.getElementById('deviceTypeSelect').value;
const seismoFields = document.getElementById('seismographFields');
const modemFields = document.getElementById('modemFields');
if (deviceType === 'seismograph') {
seismoFields.classList.remove('hidden');
modemFields.classList.add('hidden');
toggleModemPairing(); // Check if modem pairing should be shown
} else {
seismoFields.classList.add('hidden');
modemFields.classList.remove('hidden');
}
}
// Toggle modem pairing field visibility (only for deployed seismographs)
function toggleModemPairing() {
const deviceType = document.getElementById('deviceTypeSelect').value;
const deployedCheckbox = document.getElementById('deployedCheckbox');
const modemPairingField = document.getElementById('modemPairingField');
if (deviceType === 'seismograph' && deployedCheckbox.checked) {
modemPairingField.classList.remove('hidden');
} else {
modemPairingField.classList.add('hidden');
}
}
// Add unknown unit to roster // Add unknown unit to roster
function addUnknownUnit(unitId) { function addUnknownUnit(unitId) {
openAddUnitModal(); openAddUnitModal();
@@ -167,6 +360,8 @@
document.querySelector('#addUnitForm input[name="id"]').value = unitId; document.querySelector('#addUnitForm input[name="id"]').value = unitId;
// Set deployed to true by default // Set deployed to true by default
document.querySelector('#addUnitForm input[name="deployed"]').checked = true; document.querySelector('#addUnitForm input[name="deployed"]').checked = true;
// Trigger field visibility updates
toggleModemPairing(); // Show modem pairing field for deployed seismographs
} }
// Ignore unknown unit // Ignore unknown unit
@@ -220,6 +415,184 @@
} }
}); });
// Edit Unit Modal Functions
function openEditUnitModal() {
document.getElementById('editUnitModal').classList.remove('hidden');
}
function closeEditUnitModal() {
document.getElementById('editUnitModal').classList.add('hidden');
document.getElementById('editUnitForm').reset();
}
// Toggle device-specific fields in edit modal
function toggleEditDeviceFields() {
const deviceType = document.getElementById('editDeviceTypeSelect').value;
const seismoFields = document.getElementById('editSeismographFields');
const modemFields = document.getElementById('editModemFields');
if (deviceType === 'seismograph') {
seismoFields.classList.remove('hidden');
modemFields.classList.add('hidden');
toggleEditModemPairing();
} else {
seismoFields.classList.add('hidden');
modemFields.classList.remove('hidden');
}
}
// Toggle modem pairing field in edit modal
function toggleEditModemPairing() {
const deviceType = document.getElementById('editDeviceTypeSelect').value;
const deployedCheckbox = document.getElementById('editDeployedCheckbox');
const modemPairingField = document.getElementById('editModemPairingField');
if (deviceType === 'seismograph' && deployedCheckbox.checked) {
modemPairingField.classList.remove('hidden');
} else {
modemPairingField.classList.add('hidden');
}
}
// Edit Unit - Fetch data and populate form
async function editUnit(unitId) {
try {
const response = await fetch(`/api/roster/${unitId}`);
if (!response.ok) {
throw new Error('Failed to fetch unit data');
}
const unit = await response.json();
// Populate form fields
document.getElementById('editUnitId').value = unit.id;
document.getElementById('editDeviceTypeSelect').value = unit.device_type;
document.getElementById('editUnitType').value = unit.unit_type;
document.getElementById('editProjectId').value = unit.project_id;
document.getElementById('editLocation').value = unit.location;
document.getElementById('editNote').value = unit.note;
// Checkboxes
document.getElementById('editDeployedCheckbox').checked = unit.deployed;
document.getElementById('editRetiredCheckbox').checked = unit.retired;
// Seismograph fields
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
document.getElementById('editDeployedWithModemId').value = unit.deployed_with_modem_id;
// Modem fields
document.getElementById('editIpAddress').value = unit.ip_address;
document.getElementById('editPhoneNumber').value = unit.phone_number;
document.getElementById('editHardwareModel').value = unit.hardware_model;
// Set form action
document.getElementById('editUnitForm').setAttribute('hx-post', `/api/roster/edit/${unitId}`);
// Show/hide fields based on device type
toggleEditDeviceFields();
// Open modal
openEditUnitModal();
} catch (error) {
alert(`Error loading unit data: ${error.message}`);
}
}
// Handle Edit Unit form submission
document.getElementById('editUnitForm').addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
closeEditUnitModal();
// Trigger roster refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
alert('Unit updated successfully!');
} else {
alert('Error updating unit. Please check the form and try again.');
}
});
// Toggle Deployed Status
async function toggleDeployed(unitId, deployed) {
const action = deployed ? 'deploy' : 'bench';
if (!confirm(`Are you sure you want to ${action} unit ${unitId}?`)) {
return;
}
try {
const formData = new FormData();
formData.append('deployed', deployed);
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
method: 'POST',
body: formData
});
if (response.ok) {
// Trigger roster refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
} else {
const result = await response.json();
alert(`Error: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Move to Ignore List
async function moveToIgnore(unitId) {
const reason = prompt(`Why are you ignoring unit ${unitId}?`, '');
if (reason === null) {
return; // User cancelled
}
try {
const formData = new FormData();
formData.append('reason', reason);
const response = await fetch(`/api/roster/ignore/${unitId}`, {
method: 'POST',
body: formData
});
if (response.ok) {
// Trigger roster refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
alert(`Unit ${unitId} moved to ignore list`);
} else {
const result = await response.json();
alert(`Error: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Delete Unit
async function deleteUnit(unitId) {
if (!confirm(`Are you sure you want to PERMANENTLY delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
return;
}
try {
const response = await fetch(`/api/roster/${unitId}`, {
method: 'DELETE'
});
if (response.ok) {
// Trigger roster refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
alert(`Unit ${unitId} deleted successfully`);
} else {
const result = await response.json();
alert(`Error: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Handle CSV Import // Handle CSV Import
document.getElementById('importForm').addEventListener('submit', async function(e) { document.getElementById('importForm').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Unit {{ unit_id }} - Seismo Fleet Manager{% endblock %} {% block title %}Unit Detail - Seismo Fleet Manager{% endblock %}
{% block content %} {% block content %}
<div class="mb-6"> <div class="mb-6">
@@ -10,259 +10,384 @@
</svg> </svg>
Back to Fleet Roster Back to Fleet Roster
</a> </a>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Unit {{ unit_id }}</h1> <div class="flex justify-between items-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white" id="pageTitle">Loading...</h1>
<button onclick="deleteUnit()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Delete Unit
</button>
</div>
</div> </div>
<!-- Auto-refresh unit data --> <!-- Loading state -->
<div hx-get="/api/unit/{{ unit_id }}" hx-trigger="load, every 10s" hx-swap="none" hx-on::after-request="updateUnitData(event)"> <div id="loadingState" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-12 text-center">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="animate-pulse">
<!-- Left Column: Unit Info --> <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mx-auto mb-4"></div>
<div class="space-y-6"> <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 mx-auto"></div>
</div>
</div>
<!-- Main content (hidden until loaded) -->
<div id="mainContent" class="hidden space-y-6">
<!-- Status Card --> <!-- Status Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Status</h2> <div class="flex justify-between items-start mb-6">
<div class="space-y-4"> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Status</h2>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">Status</span>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span id="status-indicator" class="w-3 h-3 rounded-full bg-gray-400"></span> <span id="statusIndicator" class="w-3 h-3 rounded-full"></span>
<span id="status-text" class="font-semibold">Loading...</span> <span id="statusText" class="font-semibold"></span>
</div> </div>
</div> </div>
<div class="flex items-center justify-between"> <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
<span id="deployed-status" class="font-semibold">--</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">Age</span>
<span id="age-value" class="font-semibold">--</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">Last Seen</span>
<span id="last-seen-value" class="font-semibold">--</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">Last File</span>
<span id="last-file-value" class="font-mono text-sm">--</span>
</div>
</div>
</div>
<!-- Notes Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Notes</h2>
<div id="notes-content" class="text-gray-600 dark:text-gray-400">
Loading...
</div>
</div>
<!-- Metadata Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Edit Metadata</h2>
<form class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <span class="text-sm text-gray-500 dark:text-gray-400">Last Seen</span>
Unit Note <p id="lastSeen" class="font-medium text-gray-900 dark:text-white">--</p>
</label>
<textarea
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-transparent"
rows="3"
placeholder="Enter notes about this unit...">
</textarea>
</div> </div>
<button <div>
type="button" <span class="text-sm text-gray-500 dark:text-gray-400">Age</span>
class="w-full px-4 py-2 bg-seismo-orange hover:bg-seismo-burgundy text-white rounded-lg font-medium transition-colors" <p id="age" class="font-medium text-gray-900 dark:text-white">--</p>
onclick="alert('Mock: Save functionality not implemented')"> </div>
<div>
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed</span>
<p id="deployedStatus" class="font-medium text-gray-900 dark:text-white">--</p>
</div>
<div>
<span class="text-sm text-gray-500 dark:text-gray-400">Retired</span>
<p id="retiredStatus" class="font-medium text-gray-900 dark:text-white">--</p>
</div>
</div>
</div>
<!-- Location Map -->
<div id="mapCard" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location</h2>
<div id="unit-map" class="w-full h-64 rounded-lg mb-4"></div>
<p id="locationText" class="text-sm text-gray-500 dark:text-gray-400"></p>
</div>
<!-- Edit Unit Form -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Unit Information</h2>
<form id="editForm" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Device Type -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Device Type</label>
<select name="device_type" id="deviceType" onchange="toggleDetailFields()"
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">
<option value="seismograph">Seismograph</option>
<option value="modem">Modem</option>
</select>
</div>
<!-- Unit Type -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit Type</label>
<input type="text" name="unit_type" id="unitType"
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">
</div>
<!-- Project ID -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
<input type="text" name="project_id" id="projectId"
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">
</div>
<!-- Location -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
<input type="text" name="location" id="location"
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">
</div>
</div>
<!-- Seismograph Fields -->
<div id="seismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Seismograph Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
<input type="date" name="last_calibrated" id="lastCalibrated"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
<input type="date" name="next_calibration_due" id="nextCalibrationDue"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
<input type="text" name="deployed_with_modem_id" id="deployedWithModemId" placeholder="Modem ID"
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">
</div>
</div>
</div>
<!-- Modem Fields -->
<div id="modemFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Modem Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">IP Address</label>
<input type="text" name="ip_address" id="ipAddress" placeholder="192.168.1.100"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Phone Number</label>
<input type="text" name="phone_number" id="phoneNumber" placeholder="+1-555-0123"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hardware Model</label>
<input type="text" name="hardware_model" id="hardwareModel" placeholder="e.g., Raven XTV"
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">
</div>
</div>
</div>
<!-- Checkboxes -->
<div class="flex items-center gap-6 border-t border-gray-200 dark:border-gray-700 pt-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="deployed" id="deployed" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="retired" id="retired" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Retired</span>
</label>
</div>
<!-- Notes -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea name="note" id="note" rows="4"
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"></textarea>
</div>
<!-- Save Button -->
<div class="flex gap-3">
<button type="submit" class="flex-1 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
Save Changes Save Changes
</button> </button>
<a href="/roster" class="px-6 py-3 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg font-medium transition-colors">
Cancel
</a>
</div>
</form> </form>
</div> </div>
</div>
<!-- Right Column: Tabbed Interface -->
<div class="space-y-6">
<!-- Tabs -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex -mb-px">
<button
onclick="switchTab('photos')"
id="tab-photos"
class="tab-button flex-1 py-4 px-6 text-center border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
Photos
</button>
<button
onclick="switchTab('map')"
id="tab-map"
class="tab-button flex-1 py-4 px-6 text-center border-b-2 font-medium text-sm transition-colors border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
Map
</button>
<button
onclick="switchTab('history')"
id="tab-history"
class="tab-button flex-1 py-4 px-6 text-center border-b-2 font-medium text-sm transition-colors border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
History
</button>
</nav>
</div>
<!-- Tab Content -->
<div class="p-6">
<!-- Photos Tab -->
<div id="content-photos" class="tab-content">
<div hx-get="/api/unit/{{ unit_id }}/photos" hx-trigger="load" hx-swap="none" hx-on::after-request="updatePhotos(event)">
<div id="photos-container" class="text-center">
<div class="animate-pulse">
<div class="h-64 bg-gray-200 dark:bg-gray-700 rounded-lg mb-4"></div>
<div class="grid grid-cols-4 gap-2">
<div class="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Map Tab -->
<div id="content-map" class="tab-content hidden">
<div id="map" style="height: 500px; width: 100%;" class="rounded-lg"></div>
<div id="location-info" class="mt-4 text-sm text-gray-600 dark:text-gray-400">
<p><strong>Location:</strong> <span id="location-name">Loading...</span></p>
<p><strong>Coordinates:</strong> <span id="coordinates">--</span></p>
</div>
</div>
<!-- History Tab -->
<div id="content-history" class="tab-content hidden">
<div class="text-center text-gray-500 dark:text-gray-400 py-12">
<svg class="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-lg font-medium">Event History</p>
<p class="text-sm mt-2">Event history will be displayed here</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<script> <script>
let unitData = null; const unitId = "{{ unit_id }}";
let map = null; let currentUnit = null;
let marker = null;
function switchTab(tabName) { // Load unit data on page load
// Update tab buttons async function loadUnitData() {
document.querySelectorAll('.tab-button').forEach(btn => { try {
btn.classList.remove('border-seismo-orange', 'text-seismo-orange'); const response = await fetch(`/api/roster/${unitId}`);
btn.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400'); if (!response.ok) {
}); throw new Error('Unit not found');
document.getElementById(`tab-${tabName}`).classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400'); }
document.getElementById(`tab-${tabName}`).classList.add('border-seismo-orange', 'text-seismo-orange');
// Update tab content currentUnit = await response.json();
document.querySelectorAll('.tab-content').forEach(content => { populateForm();
content.classList.add('hidden');
});
document.getElementById(`content-${tabName}`).classList.remove('hidden');
// Initialize map if switching to map tab // Hide loading, show content
if (tabName === 'map' && !map && unitData) { document.getElementById('loadingState').classList.add('hidden');
setTimeout(() => initMap(), 100); document.getElementById('mainContent').classList.remove('hidden');
} catch (error) {
alert(`Error loading unit: ${error.message}`);
window.location.href = '/roster';
} }
} }
function updateUnitData(event) { // Populate form with unit data
try { function populateForm() {
unitData = JSON.parse(event.detail.xhr.response); // Update page title
document.getElementById('pageTitle').textContent = `Unit ${currentUnit.id}`;
// Update status // Status info
const statusIndicator = document.getElementById('status-indicator');
const statusText = document.getElementById('status-text');
const statusColors = { const statusColors = {
'OK': 'bg-green-500', 'OK': 'bg-green-500',
'Pending': 'bg-yellow-500', 'Pending': 'bg-yellow-500',
'Missing': 'bg-red-500' 'Missing': 'bg-red-500'
}; };
statusIndicator.className = `w-3 h-3 rounded-full ${statusColors[unitData.status] || 'bg-gray-400'}`; document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors.OK || 'bg-gray-400'}`;
statusText.textContent = unitData.status; document.getElementById('statusText').textContent = 'No status data';
statusText.className = `font-semibold ${unitData.status === 'OK' ? 'text-green-600 dark:text-green-400' : unitData.status === 'Pending' ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400'}`; document.getElementById('lastSeen').textContent = '--';
document.getElementById('age').textContent = '--';
document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
document.getElementById('retiredStatus').textContent = currentUnit.retired ? 'Yes' : 'No';
// Update other fields // Form fields
document.getElementById('deployed-status').textContent = unitData.deployed ? '✓ Deployed' : '✗ Benched'; document.getElementById('deviceType').value = currentUnit.device_type;
document.getElementById('age-value').textContent = unitData.age; document.getElementById('unitType').value = currentUnit.unit_type;
document.getElementById('last-seen-value').textContent = unitData.last_seen; document.getElementById('projectId').value = currentUnit.project_id;
document.getElementById('last-file-value').textContent = unitData.last_file; document.getElementById('location').value = currentUnit.location;
document.getElementById('notes-content').textContent = unitData.note || 'No notes available'; document.getElementById('deployed').checked = currentUnit.deployed;
document.getElementById('retired').checked = currentUnit.retired;
document.getElementById('note').value = currentUnit.note;
// Update location info // Seismograph fields
if (unitData.coordinates) { document.getElementById('lastCalibrated').value = currentUnit.last_calibrated;
document.getElementById('location-name').textContent = unitData.coordinates.location; document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due;
document.getElementById('coordinates').textContent = `${unitData.coordinates.lat.toFixed(4)}, ${unitData.coordinates.lon.toFixed(4)}`; document.getElementById('deployedWithModemId').value = currentUnit.deployed_with_modem_id;
}
} catch (error) { // Modem fields
console.error('Error updating unit data:', error); document.getElementById('ipAddress').value = currentUnit.ip_address;
document.getElementById('phoneNumber').value = currentUnit.phone_number;
document.getElementById('hardwareModel').value = currentUnit.hardware_model;
// Show/hide fields based on device type
toggleDetailFields();
// Update map with unit location
updateUnitMap(currentUnit);
}
// Toggle device-specific fields
function toggleDetailFields() {
const deviceType = document.getElementById('deviceType').value;
const seismoFields = document.getElementById('seismographFields');
const modemFields = document.getElementById('modemFields');
if (deviceType === 'seismograph') {
seismoFields.classList.remove('hidden');
modemFields.classList.add('hidden');
} else {
seismoFields.classList.add('hidden');
modemFields.classList.remove('hidden');
} }
} }
function updatePhotos(event) { // Handle form submission
document.getElementById('editForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
try { try {
const data = JSON.parse(event.detail.xhr.response); const response = await fetch(`/api/roster/edit/${unitId}`, {
const container = document.getElementById('photos-container'); method: 'POST',
body: formData
if (data.photos.length === 0) {
container.innerHTML = `
<div class="text-center text-gray-500 dark:text-gray-400 py-12">
<svg class="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<p class="text-lg font-medium">No Photos Available</p>
<p class="text-sm mt-2">Photos will appear here when uploaded</p>
</div>
`;
} else {
let html = `
<div class="mb-4">
<img src="${data.photo_urls[0]}" alt="Primary photo" class="w-full h-auto rounded-lg shadow-lg" id="primary-image">
</div>
<div class="grid grid-cols-4 gap-2">
`;
data.photo_urls.forEach((url, index) => {
html += `
<img src="${url}" alt="Photo ${index + 1}"
class="w-full h-20 object-cover rounded cursor-pointer hover:opacity-75 transition-opacity"
onclick="document.getElementById('primary-image').src = this.src">
`;
}); });
html += '</div>'; if (response.ok) {
container.innerHTML = html; alert('Unit updated successfully!');
loadUnitData(); // Reload data
} else {
const result = await response.json();
alert(`Error: ${result.detail || 'Unknown error'}`);
} }
} catch (error) { } catch (error) {
console.error('Error updating photos:', error); alert(`Error: ${error.message}`);
}
});
// Delete unit
async function deleteUnit() {
if (!confirm(`Are you sure you want to PERMANENTLY delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
return;
}
try {
const response = await fetch(`/api/roster/${unitId}`, {
method: 'DELETE'
});
if (response.ok) {
alert('Unit deleted successfully');
window.location.href = '/roster';
} else {
const result = await response.json();
alert(`Error: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error: ${error.message}`);
} }
} }
function initMap() { // Initialize unit location map
if (!unitData || !unitData.coordinates) return; let unitMap = null;
const coords = unitData.coordinates; function initUnitMap() {
map = L.map('map').setView([coords.lat, coords.lon], 13); // Default center (will be updated when location is loaded)
unitMap = L.map('unit-map').setView([39.8283, -98.5795], 4);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors' attribution: '© OpenStreetMap contributors',
}).addTo(map); maxZoom: 18
}).addTo(unitMap);
marker = L.marker([coords.lat, coords.lon]).addTo(map)
.bindPopup(`<b>${unitData.id}</b><br>${coords.location}`)
.openPopup();
} }
function updateUnitMap(unit) {
if (!unit.location || !unitMap) {
document.getElementById('mapCard').classList.add('hidden');
return;
}
const coords = parseLocation(unit.location);
if (coords) {
const [lat, lon] = coords;
// Show the map card
document.getElementById('mapCard').classList.remove('hidden');
// Center map on unit location
unitMap.setView([lat, lon], 13);
// Add marker with unit info
const statusColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'orange' : 'red';
L.circleMarker([lat, lon], {
radius: 10,
fillColor: statusColor,
color: '#fff',
weight: 3,
opacity: 1,
fillOpacity: 0.8
}).addTo(unitMap).bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg">${unit.id}</h3>
<p class="text-sm">Status: <span style="color: ${statusColor}">${unit.status || 'Unknown'}</span></p>
<p class="text-sm">Type: ${unit.device_type}</p>
</div>
`).openPopup();
// Update location text
document.getElementById('locationText').textContent = `Coordinates: ${lat.toFixed(6)}, ${lon.toFixed(6)}`;
} else {
// Show map card but indicate location not mappable
document.getElementById('mapCard').classList.remove('hidden');
document.getElementById('locationText').textContent = `Location: ${unit.location} (coordinates not available)`;
}
}
function parseLocation(location) {
if (!location) return null;
// Try to parse as "lat,lon" format
const parts = location.split(',').map(s => s.trim());
if (parts.length === 2) {
const lat = parseFloat(parts[0]);
const lon = parseFloat(parts[1]);
if (!isNaN(lat) && !isNaN(lon)) {
return [lat, lon];
}
}
// TODO: Add geocoding support for address strings
return null;
}
// Load data when page loads
initUnitMap();
loadUnitData();
</script> </script>
{% endblock %} {% endblock %}