merge dev to main update to v.0.2.1

update to v.0.2.1
This commit is contained in:
serversdwn
2025-12-03 16:24:51 -05:00
committed by GitHub
17 changed files with 3079 additions and 327 deletions

View File

@@ -1,12 +1,14 @@
from fastapi import FastAPI, Request from fastapi import FastAPI, Request, Depends
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from backend.database import engine, Base from backend.database import engine, Base, get_db
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs
from backend.services.snapshot import emit_status_snapshot from backend.services.snapshot import emit_status_snapshot
from backend.models import IgnoredUnit
# Create database tables # Create database tables
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -41,6 +43,9 @@ app.include_router(roster_edit.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(dashboard_tabs.router) app.include_router(dashboard_tabs.router)
from backend.routers import settings
app.include_router(settings.router)
# Legacy routes from the original backend # Legacy routes from the original backend
@@ -70,14 +75,20 @@ async def unit_detail_page(request: Request, unit_id: str):
}) })
@app.get("/partials/roster-table", response_class=HTMLResponse) @app.get("/settings", response_class=HTMLResponse)
async def roster_table_partial(request: Request): async def settings_page(request: Request):
"""Partial template for roster table (HTMX)""" """Settings page for roster management"""
return templates.TemplateResponse("settings.html", {"request": request})
@app.get("/partials/roster-deployed", response_class=HTMLResponse)
async def roster_deployed_partial(request: Request):
"""Partial template for deployed units tab"""
from datetime import datetime from datetime import datetime
snapshot = emit_status_snapshot() snapshot = emit_status_snapshot()
units_list = [] units_list = []
for unit_id, unit_data in snapshot["units"].items(): for unit_id, unit_data in snapshot["active"].items():
units_list.append({ units_list.append({
"id": unit_id, "id": unit_id,
"status": unit_data["status"], "status": unit_data["status"],
@@ -85,6 +96,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
@@ -98,6 +116,98 @@ async def roster_table_partial(request: Request):
}) })
@app.get("/partials/roster-benched", response_class=HTMLResponse)
async def roster_benched_partial(request: Request):
"""Partial template for benched units tab"""
from datetime import datetime
snapshot = emit_status_snapshot()
units_list = []
for unit_id, unit_data in snapshot["benched"].items():
units_list.append({
"id": unit_id,
"status": unit_data["status"],
"age": unit_data["age"],
"last_seen": unit_data["last"],
"deployed": unit_data["deployed"],
"note": unit_data.get("note", ""),
"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 ID
units_list.sort(key=lambda x: x["id"])
return templates.TemplateResponse("partials/roster_table.html", {
"request": request,
"units": units_list,
"timestamp": datetime.now().strftime("%H:%M:%S")
})
@app.get("/partials/roster-retired", response_class=HTMLResponse)
async def roster_retired_partial(request: Request):
"""Partial template for retired units tab"""
from datetime import datetime
snapshot = emit_status_snapshot()
units_list = []
for unit_id, unit_data in snapshot["retired"].items():
units_list.append({
"id": unit_id,
"status": unit_data["status"],
"age": unit_data["age"],
"last_seen": unit_data["last"],
"deployed": unit_data["deployed"],
"note": unit_data.get("note", ""),
"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 ID
units_list.sort(key=lambda x: x["id"])
return templates.TemplateResponse("partials/retired_table.html", {
"request": request,
"units": units_list,
"timestamp": datetime.now().strftime("%H:%M:%S")
})
@app.get("/partials/roster-ignored", response_class=HTMLResponse)
async def roster_ignored_partial(request: Request, db: Session = Depends(get_db)):
"""Partial template for ignored units tab"""
from datetime import datetime
ignored = db.query(IgnoredUnit).all()
ignored_list = []
for unit in ignored:
ignored_list.append({
"id": unit.id,
"reason": unit.reason or "",
"ignored_at": unit.ignored_at.strftime("%Y-%m-%d %H:%M:%S") if unit.ignored_at else "Unknown"
})
# Sort by ID
ignored_list.sort(key=lambda x: x["id"])
return templates.TemplateResponse("partials/ignored_table.html", {
"request": request,
"ignored_units": ignored_list,
"timestamp": datetime.now().strftime("%H:%M:%S")
})
@app.get("/partials/unknown-emitters", response_class=HTMLResponse) @app.get("/partials/unknown-emitters", response_class=HTMLResponse)
async def unknown_emitters_partial(request: Request): async def unknown_emitters_partial(request: Request):
"""Partial template for unknown emitters (HTMX)""" """Partial template for unknown emitters (HTMX)"""

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,18 +18,34 @@ 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)
project_id = Column(String, nullable=True) project_id = Column(String, nullable=True)
location = Column(String, nullable=True) location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
address = Column(String, nullable=True) # Human-readable address
coordinates = Column(String, nullable=True) # Lat,Lon format: "34.0522,-118.2437"
last_updated = Column(DateTime, default=datetime.utcnow) 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,11 +1,11 @@
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
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit, IgnoredUnit from backend.models import RosterUnit, IgnoredUnit, Emitter
router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
@@ -23,28 +23,161 @@ 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),
retired: 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),
address: str = Form(None),
coordinates: str = Form(None),
# Seismograph-specific fields
last_calibrated: str = Form(None),
next_calibration_due: str = Form(None),
deployed_with_modem_id: str = Form(None),
# Modem-specific fields
ip_address: str = Form(None),
phone_number: str = Form(None),
hardware_model: str = Form(None),
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,
retired=retired,
note=note, note=note,
project_id=project_id, project_id=project_id,
location=location, location=location,
address=address,
coordinates=coordinates,
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 "",
"address": unit.address or "",
"coordinates": unit.coordinates or "",
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
"ip_address": unit.ip_address or "",
"phone_number": unit.phone_number or "",
"hardware_model": unit.hardware_model or "",
}
@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),
address: str = Form(None),
coordinates: str = Form(None),
# Seismograph-specific fields
last_calibrated: str = Form(None),
next_calibration_due: str = Form(None),
deployed_with_modem_id: str = Form(None),
# Modem-specific fields
ip_address: str = Form(None),
phone_number: str = Form(None),
hardware_model: str = Form(None),
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.address = address
unit.coordinates = coordinates
unit.last_updated = datetime.utcnow()
# Seismograph-specific fields
unit.last_calibrated = last_cal_date
unit.next_calibration_due = next_cal_date
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
# Modem-specific fields
unit.ip_address = ip_address if ip_address else None
unit.phone_number = phone_number if phone_number else None
unit.hardware_model = hardware_model if hardware_model else None
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 +198,34 @@ 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 database.
Checks both roster and emitters tables and deletes from any table where the unit exists.
"""
deleted = False
# Try to delete from roster table
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if roster_unit:
db.delete(roster_unit)
deleted = True
# Try to delete from emitters table
emitter = db.query(Emitter).filter(Emitter.id == unit_id).first()
if emitter:
db.delete(emitter)
deleted = True
# If not found in either table, return error
if not deleted:
raise HTTPException(status_code=404, detail="Unit not found")
db.commit()
return {"message": "Unit deleted", "id": unit_id}
@router.post("/set-note/{unit_id}") @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)
@@ -138,6 +299,8 @@ async def import_csv(
existing_unit.note = row.get('note', existing_unit.note or '') existing_unit.note = row.get('note', existing_unit.note or '')
existing_unit.project_id = row.get('project_id', existing_unit.project_id) existing_unit.project_id = row.get('project_id', existing_unit.project_id)
existing_unit.location = row.get('location', existing_unit.location) existing_unit.location = row.get('location', existing_unit.location)
existing_unit.address = row.get('address', existing_unit.address)
existing_unit.coordinates = row.get('coordinates', existing_unit.coordinates)
existing_unit.last_updated = datetime.utcnow() existing_unit.last_updated = datetime.utcnow()
results["updated"].append(unit_id) results["updated"].append(unit_id)
@@ -151,6 +314,8 @@ async def import_csv(
note=row.get('note', ''), note=row.get('note', ''),
project_id=row.get('project_id'), project_id=row.get('project_id'),
location=row.get('location'), location=row.get('location'),
address=row.get('address'),
coordinates=row.get('coordinates'),
last_updated=datetime.utcnow() last_updated=datetime.utcnow()
) )
db.add(new_unit) db.add(new_unit)

241
backend/routers/settings.py Normal file
View File

@@ -0,0 +1,241 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from datetime import datetime, date
import csv
import io
from backend.database import get_db
from backend.models import RosterUnit, Emitter, IgnoredUnit
router = APIRouter(prefix="/api/settings", tags=["settings"])
@router.get("/export-csv")
def export_roster_csv(db: Session = Depends(get_db)):
"""Export all roster units to CSV"""
units = db.query(RosterUnit).all()
# Create CSV in memory
output = io.StringIO()
fieldnames = [
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
'note', 'project_id', 'location', 'address', 'coordinates',
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
'ip_address', 'phone_number', 'hardware_model'
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
for unit in units:
writer.writerow({
'unit_id': unit.id,
'unit_type': unit.unit_type or '',
'device_type': unit.device_type or 'seismograph',
'deployed': 'true' if unit.deployed else 'false',
'retired': 'true' if unit.retired else 'false',
'note': unit.note or '',
'project_id': unit.project_id or '',
'location': unit.location or '',
'address': unit.address or '',
'coordinates': unit.coordinates or '',
'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '',
'deployed_with_modem_id': unit.deployed_with_modem_id or '',
'ip_address': unit.ip_address or '',
'phone_number': unit.phone_number or '',
'hardware_model': unit.hardware_model or ''
})
output.seek(0)
filename = f"roster_export_{date.today().isoformat()}.csv"
return StreamingResponse(
io.BytesIO(output.getvalue().encode('utf-8')),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@router.get("/stats")
def get_table_stats(db: Session = Depends(get_db)):
"""Get counts for all tables"""
roster_count = db.query(RosterUnit).count()
emitters_count = db.query(Emitter).count()
ignored_count = db.query(IgnoredUnit).count()
return {
"roster": roster_count,
"emitters": emitters_count,
"ignored": ignored_count,
"total": roster_count + emitters_count + ignored_count
}
@router.get("/roster-units")
def get_all_roster_units(db: Session = Depends(get_db)):
"""Get all roster units for management table"""
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
return [{
"id": unit.id,
"device_type": unit.device_type or "seismograph",
"unit_type": unit.unit_type or "series3",
"deployed": unit.deployed,
"retired": unit.retired,
"note": unit.note or "",
"project_id": unit.project_id or "",
"location": unit.location or "",
"address": unit.address or "",
"coordinates": unit.coordinates or "",
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
"ip_address": unit.ip_address or "",
"phone_number": unit.phone_number or "",
"hardware_model": unit.hardware_model or "",
"last_updated": unit.last_updated.isoformat() if unit.last_updated else None
} for unit in units]
def parse_date(date_str):
"""Helper function to parse date strings"""
if not date_str or not date_str.strip():
return None
try:
return datetime.strptime(date_str.strip(), "%Y-%m-%d").date()
except ValueError:
return None
@router.post("/import-csv-replace")
async def import_csv_replace(
file: UploadFile = File(...),
db: Session = Depends(get_db)
):
"""
Replace all roster data with CSV import (atomic transaction).
Clears roster table first, then imports all rows from CSV.
"""
if not file.filename.endswith('.csv'):
raise HTTPException(status_code=400, detail="File must be a CSV")
# Read and parse CSV
contents = await file.read()
csv_text = contents.decode('utf-8')
csv_reader = csv.DictReader(io.StringIO(csv_text))
# Parse all rows FIRST (fail fast before deletion)
parsed_units = []
for row_num, row in enumerate(csv_reader, start=2):
unit_id = row.get('unit_id', '').strip()
if not unit_id:
raise HTTPException(
status_code=400,
detail=f"Row {row_num}: Missing required field unit_id"
)
# Parse and validate dates
last_cal_date = parse_date(row.get('last_calibrated'))
next_cal_date = parse_date(row.get('next_calibration_due'))
parsed_units.append({
'id': unit_id,
'unit_type': row.get('unit_type', 'series3'),
'device_type': row.get('device_type', 'seismograph'),
'deployed': row.get('deployed', '').lower() in ('true', '1', 'yes'),
'retired': row.get('retired', '').lower() in ('true', '1', 'yes'),
'note': row.get('note', ''),
'project_id': row.get('project_id') or None,
'location': row.get('location') or None,
'address': row.get('address') or None,
'coordinates': row.get('coordinates') or None,
'last_calibrated': last_cal_date,
'next_calibration_due': next_cal_date,
'deployed_with_modem_id': row.get('deployed_with_modem_id') or None,
'ip_address': row.get('ip_address') or None,
'phone_number': row.get('phone_number') or None,
'hardware_model': row.get('hardware_model') or None,
})
# Atomic transaction: delete all, then insert all
try:
deleted_count = db.query(RosterUnit).delete()
for unit_data in parsed_units:
new_unit = RosterUnit(**unit_data, last_updated=datetime.utcnow())
db.add(new_unit)
db.commit()
return {
"message": "Roster replaced successfully",
"deleted": deleted_count,
"added": len(parsed_units)
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
@router.post("/clear-all")
def clear_all_data(db: Session = Depends(get_db)):
"""Clear all tables (roster, emitters, ignored)"""
try:
roster_count = db.query(RosterUnit).delete()
emitters_count = db.query(Emitter).delete()
ignored_count = db.query(IgnoredUnit).delete()
db.commit()
return {
"message": "All data cleared",
"deleted": {
"roster": roster_count,
"emitters": emitters_count,
"ignored": ignored_count,
"total": roster_count + emitters_count + ignored_count
}
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
@router.post("/clear-roster")
def clear_roster(db: Session = Depends(get_db)):
"""Clear roster table only"""
try:
count = db.query(RosterUnit).delete()
db.commit()
return {"message": "Roster cleared", "deleted": count}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
@router.post("/clear-emitters")
def clear_emitters(db: Session = Depends(get_db)):
"""Clear emitters table only"""
try:
count = db.query(Emitter).delete()
db.commit()
return {"message": "Emitters cleared", "deleted": count}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
@router.post("/clear-ignored")
def clear_ignored(db: Session = Depends(get_db)):
"""Clear ignored units table only"""
try:
count = db.query(IgnoredUnit).delete()
db.commit()
return {"message": "Ignored units cleared", "deleted": count}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")

View File

@@ -69,6 +69,18 @@ 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 "",
"address": r.address or "",
"coordinates": r.coordinates or "",
} }
# --- Add unexpected emitter-only units --- # --- Add unexpected emitter-only units ---
@@ -84,6 +96,18 @@ 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,
# Location fields
"location": "",
"address": "",
"coordinates": "",
} }
# Separate buckets for UI # Separate buckets for UI
@@ -121,9 +145,10 @@ def emit_status_snapshot():
"benched": len(benched_units), "benched": len(benched_units),
"retired": len(retired_units), "retired": len(retired_units),
"unknown": len(unknown_units), "unknown": len(unknown_units),
"ok": sum(1 for u in units.values() if u["status"] == "OK"), # Status counts only for deployed units (active_units)
"pending": sum(1 for u in units.values() if u["status"] == "Pending"), "ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
"missing": sum(1 for u in units.values() if u["status"] == "Missing"), "pending": sum(1 for u in active_units.values() if u["status"] == "Pending"),
"missing": sum(1 for u in active_units.values() if u["status"] == "Missing"),
} }
} }
finally: finally:

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

@@ -68,7 +68,7 @@
Seismo<br> Seismo<br>
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span> <span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
</h1> </h1>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">v0.1.1</p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-2">v 0.2.1</p>
</div> </div>
<!-- Navigation --> <!-- Navigation -->
@@ -94,7 +94,7 @@
Projects Projects
</a> </a>
<a href="#" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 opacity-50 cursor-not-allowed"> <a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>

View File

@@ -35,7 +35,12 @@
<span class="text-gray-600 dark:text-gray-400">Deployed</span> <span class="text-gray-600 dark:text-gray-400">Deployed</span>
<span id="deployed-units" class="text-2xl font-bold text-blue-600 dark:text-blue-400">--</span> <span id="deployed-units" class="text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
</div> </div>
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Benched</span>
<span id="benched-units" class="text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"> <div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
<div class="flex items-center"> <div class="flex items-center">
<span class="w-3 h-3 rounded-full bg-green-500 mr-2"></span> <span class="w-3 h-3 rounded-full bg-green-500 mr-2"></span>
@@ -98,6 +103,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 +146,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>
@@ -164,14 +181,16 @@ function updateDashboard(event) {
// ===== Fleet summary numbers ===== // ===== Fleet summary numbers =====
document.getElementById('total-units').textContent = data.summary?.total ?? 0; document.getElementById('total-units').textContent = data.summary?.total ?? 0;
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0; document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0; document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0; document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0; document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
// ===== Alerts ===== // ===== Alerts =====
const alertsList = document.getElementById('alerts-list'); const alertsList = document.getElementById('alerts-list');
const missingUnits = Object.entries(data.units).filter(([_, u]) => u.status === 'Missing'); // Only show alerts for deployed units (not benched)
const pendingUnits = Object.entries(data.units).filter(([_, u]) => u.status === 'Pending'); const missingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Missing');
const pendingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Pending');
if (!missingUnits.length && !pendingUnits.length) { if (!missingUnits.length && !pendingUnits.length) {
alertsList.innerHTML = alertsList.innerHTML =
@@ -204,10 +223,118 @@ 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 = [];
let fleetMapInitialized = false;
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 coordinates data
const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.coordinates);
if (deployedUnits.length === 0) {
return;
}
const bounds = [];
deployedUnits.forEach(([id, unit]) => {
const coords = parseLocation(unit.coordinates);
if (coords) {
const [lat, lon] = coords;
// Create marker with custom color based on status
const markerColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'orange' : 'red';
const marker = L.circleMarker([lat, lon], {
radius: 8,
fillColor: markerColor,
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(fleetMap);
// Add popup with unit info
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg">${id}</h3>
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
<p class="text-sm">Type: ${unit.device_type}</p>
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details →</a>
</div>
`);
fleetMarkers.push(marker);
bounds.push([lat, lon]);
}
});
// Fit map to show all markers only on first load
if (bounds.length > 0 && !fleetMapInitialized) {
fleetMap.fitBounds(bounds, { padding: [50, 50] });
fleetMapInitialized = true;
}
}
function parseLocation(location) {
if (!location) return null;
// Try to parse as "lat,lon" format
const parts = location.split(',').map(s => s.trim());
if (parts.length === 2) {
const lat = parseFloat(parts[0]);
const lon = parseFloat(parts[1]);
if (!isNaN(lat) && !isNaN(lon)) {
return [lat, lon];
}
}
// TODO: Add geocoding support for address strings
return null;
}
</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>
{% endfor %} {% else %}
</tbody> <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">
</table> Seismograph
</span>
{% endif %}
</div>
{% if unit.note %}
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ unit.note }}</p>
{% endif %}
</div>
<!-- Age -->
<div class="text-right flex-shrink-0">
<span class="text-sm {% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400{% else %}text-gray-500 dark:text-gray-400{% endif %}">
{{ unit.age }}
</span>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<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>
{% endfor %} {% else %}
</tbody> <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">
</table> Seismograph
</span>
{% endif %}
</div>
{% if unit.note %}
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ unit.note }}</p>
{% endif %}
</div>
<!-- Last Activity (if any) -->
<div class="text-right flex-shrink-0">
{% if unit.age != 'N/A' %}
<span class="text-sm text-gray-400 dark:text-gray-500">
Last seen: {{ unit.age }} ago
</span>
{% else %}
<span class="text-sm text-gray-400 dark:text-gray-500">
No data
</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-8">No benched units</p>
{% endif %}

View File

@@ -0,0 +1,69 @@
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Unit ID
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Reason
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Ignored At
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
{% if ignored_units %}
{% for unit in ignored_units %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-gray-400" title="Ignored"></span>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ unit.id }}
</span>
</div>
</td>
<td class="px-6 py-4">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ unit.reason }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-500 dark:text-gray-400">{{ unit.ignored_at }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex justify-end gap-2">
<button onclick="unignoreUnit('{{ unit.id }}')"
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Un-ignore Unit">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</button>
<button onclick="deleteIgnoredUnit('{{ unit.id }}')"
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
No ignored units
</td>
</tr>
{% endif %}
</tbody>
</table>
<!-- Last updated indicator -->
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
Last updated: {{ timestamp }}
</div>
</div>

View File

@@ -0,0 +1,77 @@
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Unit ID
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Type
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Note
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
{% if units %}
{% for unit in units %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-gray-500" title="Retired"></span>
<a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
{{ unit.id }}
</a>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if unit.device_type == 'modem' %}
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
Modem
</span>
{% else %}
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
Seismograph
</span>
{% endif %}
</td>
<td class="px-6 py-4">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ unit.note }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex justify-end gap-2">
<button onclick="editUnit('{{ unit.id }}')"
class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</button>
<button onclick="deleteUnit('{{ unit.id }}')"
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
No retired units
</td>
</tr>
{% endif %}
</tbody>
</table>
<!-- Last updated indicator -->
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
Last updated: {{ timestamp }}
</div>
</div>

View File

@@ -1,30 +1,60 @@
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table id="roster-table" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700"> <thead class="bg-gray-50 dark:bg-gray-700">
<tr> <tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('status')">
Status <div class="flex items-center gap-1">
Status
<span class="sort-indicator" data-column="status"></span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('id')">
<div class="flex items-center gap-1">
Unit ID
<span class="sort-indicator" data-column="id"></span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('type')">
<div class="flex items-center gap-1">
Type
<span class="sort-indicator" data-column="type"></span>
</div>
</th> </th>
<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 Details
</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"> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('last_seen')">
Last Seen <div class="flex items-center gap-1">
Last Seen
<span class="sort-indicator" data-column="last_seen"></span>
</div>
</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"> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('age')">
Age <div class="flex items-center gap-1">
Age
<span class="sort-indicator" data-column="age"></span>
</div>
</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"> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('note')">
Note <div class="flex items-center gap-1">
Note
<span class="sort-indicator" data-column="note"></span>
</div>
</th> </th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions Actions
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700"> <tbody id="roster-tbody" class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
{% for unit in units %} {% for unit in units %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"> <tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
data-status="{{ unit.status }}"
data-id="{{ unit.id }}"
data-type="{{ unit.device_type }}"
data-last-seen="{{ unit.last_seen }}"
data-age="{{ unit.age }}"
data-note="{{ unit.note if unit.note else '' }}">
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
{% if unit.status == 'OK' %} {% if unit.status == 'OK' %}
@@ -43,7 +73,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 +136,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">
</svg> <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>
</a> </svg>
</button>
{% if unit.deployed %}
<button onclick="toggleDeployed('{{ unit.id }}', false)"
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-1" title="Mark as Benched">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
</button>
{% else %}
<button onclick="toggleDeployed('{{ unit.id }}', true)"
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Mark as Deployed">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</button>
{% endif %}
<button onclick="moveToIgnore('{{ unit.id }}')"
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-1" title="Move to Ignore List">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
</svg>
</button>
<button onclick="deleteUnit('{{ unit.id }}')"
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -81,7 +183,108 @@
</div> </div>
</div> </div>
<style>
.sort-indicator::after {
content: '⇅';
opacity: 0.3;
font-size: 12px;
}
.sort-indicator.asc::after {
content: '↑';
opacity: 1;
}
.sort-indicator.desc::after {
content: '↓';
opacity: 1;
}
</style>
<script> <script>
// Update timestamp // Update timestamp
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString(); document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
// Sorting state
let currentSort = { column: null, direction: 'asc' };
function sortTable(column) {
const tbody = document.getElementById('roster-tbody');
const rows = Array.from(tbody.getElementsByTagName('tr'));
// Determine sort direction
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
}
// Sort rows
rows.sort((a, b) => {
let aVal = a.getAttribute(`data-${column}`) || '';
let bVal = b.getAttribute(`data-${column}`) || '';
// Special handling for different column types
if (column === 'age') {
// Parse age strings like "2h 15m" or "45m" or "3d 5h"
aVal = parseAge(aVal);
bVal = parseAge(bVal);
} else if (column === 'status') {
// Sort by status priority: Missing > Pending > OK
const statusOrder = { 'Missing': 0, 'Pending': 1, 'OK': 2, '': 3 };
aVal = statusOrder[aVal] !== undefined ? statusOrder[aVal] : 999;
bVal = statusOrder[bVal] !== undefined ? statusOrder[bVal] : 999;
} else if (column === 'last_seen') {
// Sort by date
aVal = new Date(aVal).getTime() || 0;
bVal = new Date(bVal).getTime() || 0;
} else {
// String comparison (case-insensitive)
aVal = aVal.toLowerCase();
bVal = bVal.toLowerCase();
}
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
// Re-append rows in sorted order
rows.forEach(row => tbody.appendChild(row));
// Update sort indicators
updateSortIndicators();
}
function parseAge(ageStr) {
// Parse age strings like "2h 15m", "45m", "3d 5h", "2w 3d"
if (!ageStr) return 0;
let totalMinutes = 0;
const weeks = ageStr.match(/(\d+)w/);
const days = ageStr.match(/(\d+)d/);
const hours = ageStr.match(/(\d+)h/);
const minutes = ageStr.match(/(\d+)m/);
if (weeks) totalMinutes += parseInt(weeks[1]) * 7 * 24 * 60;
if (days) totalMinutes += parseInt(days[1]) * 24 * 60;
if (hours) totalMinutes += parseInt(hours[1]) * 60;
if (minutes) totalMinutes += parseInt(minutes[1]);
return totalMinutes;
}
function updateSortIndicators() {
// Clear all indicators
document.querySelectorAll('.sort-indicator').forEach(indicator => {
indicator.className = 'sort-indicator';
});
// Set current indicator
if (currentSort.column) {
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
if (indicator) {
indicator.className = `sort-indicator ${currentSort.direction}`;
}
}
}
</script> </script>

View File

@@ -31,22 +31,56 @@
<!-- Loading placeholder --> <!-- Loading placeholder -->
</div> </div>
<!-- Auto-refresh roster every 10 seconds --> <!-- Fleet Roster with Tabs -->
<div hx-get="/partials/roster-table" hx-trigger="load, every 10s" hx-swap="innerHTML"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<!-- Initial loading state -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6"> <!-- Tab Bar -->
<div class="flex items-center justify-center py-12"> <div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
<div class="animate-pulse flex space-x-4"> <button
<div class="flex-1 space-y-4 py-1"> class="px-4 py-2 text-sm font-medium roster-tab-button active-roster-tab"
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div> data-endpoint="/partials/roster-deployed"
<div class="space-y-2"> hx-get="/partials/roster-deployed"
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded"></div> hx-target="#roster-content"
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-5/6"></div> hx-swap="innerHTML">
</div> Deployed
</div> </button>
</div>
</div> <button
class="px-4 py-2 text-sm font-medium roster-tab-button"
data-endpoint="/partials/roster-benched"
hx-get="/partials/roster-benched"
hx-target="#roster-content"
hx-swap="innerHTML">
Benched
</button>
<button
class="px-4 py-2 text-sm font-medium roster-tab-button"
data-endpoint="/partials/roster-retired"
hx-get="/partials/roster-retired"
hx-target="#roster-content"
hx-swap="innerHTML">
Retired
</button>
<button
class="px-4 py-2 text-sm font-medium roster-tab-button"
data-endpoint="/partials/roster-ignored"
hx-get="/partials/roster-ignored"
hx-target="#roster-content"
hx-swap="innerHTML">
Ignored
</button>
</div> </div>
<!-- Tab Content Target -->
<div id="roster-content"
hx-get="/partials/roster-deployed"
hx-trigger="load"
hx-swap="innerHTML">
<p class="text-gray-500 dark:text-gray-400">Loading roster data...</p>
</div>
</div> </div>
<!-- Add Unit Modal --> <!-- Add Unit Modal -->
@@ -69,6 +103,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,12 +128,60 @@
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>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="retired" id="retiredCheckbox" 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>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
@@ -111,6 +201,123 @@
</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" 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">Address</label>
<input type="text" name="address" id="editAddress" placeholder="123 Main St, City, State"
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">Coordinates</label>
<input type="text" name="coordinates" id="editCoordinates" placeholder="34.0522,-118.2437"
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 font-mono">
</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 +360,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 +368,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 +404,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
@@ -211,8 +450,11 @@
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) { document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) { if (event.detail.successful) {
closeAddUnitModal(); closeAddUnitModal();
// Trigger roster refresh // Trigger roster refresh for current active tab
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load'); htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
// Show success message // Show success message
alert('Unit added successfully!'); alert('Unit added successfully!');
} else { } else {
@@ -220,6 +462,217 @@
} }
}); });
// 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('editAddress').value = unit.address;
document.getElementById('editCoordinates').value = unit.coordinates;
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;
// Store unit ID for form submission
document.getElementById('editUnitForm').dataset.unitId = 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('submit', async function(event) {
event.preventDefault();
const unitId = this.dataset.unitId;
if (!unitId) {
alert('Error: Unit ID not found');
return;
}
const formData = new FormData(this);
try {
const response = await fetch(`/api/roster/edit/${unitId}`, {
method: 'POST',
body: formData
});
if (response.ok) {
closeEditUnitModal();
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
alert('Unit updated successfully!');
} else {
const result = await response.json();
alert(`Error: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error updating unit: ${error.message}`);
}
});
// 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 for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
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 for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
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 for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
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();
@@ -248,8 +701,11 @@
`; `;
resultDiv.classList.remove('hidden'); resultDiv.classList.remove('hidden');
// Trigger roster refresh // Trigger roster refresh for current active tab
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load'); htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
// Close modal after 2 seconds // Close modal after 2 seconds
setTimeout(() => closeImportModal(), 2000); setTimeout(() => closeImportModal(), 2000);
@@ -264,6 +720,105 @@
resultDiv.classList.remove('hidden'); resultDiv.classList.remove('hidden');
} }
}); });
// Handle roster tab switching with auto-refresh
let currentRosterEndpoint = '/partials/roster-deployed'; // Default to deployed tab
document.addEventListener('DOMContentLoaded', function() {
const tabButtons = document.querySelectorAll('.roster-tab-button');
tabButtons.forEach(button => {
button.addEventListener('click', function() {
// Remove active-roster-tab class from all buttons
tabButtons.forEach(btn => btn.classList.remove('active-roster-tab'));
// Add active-roster-tab class to clicked button
this.classList.add('active-roster-tab');
// Update current endpoint for auto-refresh
currentRosterEndpoint = this.getAttribute('data-endpoint');
});
});
// Auto-refresh the current active tab every 10 seconds
setInterval(() => {
const rosterContent = document.getElementById('roster-content');
if (rosterContent) {
// Use HTMX to trigger a refresh of the current endpoint
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
}
}, 10000); // 10 seconds
});
// Un-ignore Unit (remove from ignored list)
async function unignoreUnit(unitId) {
if (!confirm(`Remove unit ${unitId} from ignore list?`)) {
return;
}
try {
const response = await fetch(`/api/roster/ignore/${unitId}`, {
method: 'DELETE'
});
if (response.ok) {
// Trigger ignored tab refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-ignored"]'), 'load');
alert(`Unit ${unitId} removed from ignore list`);
} else {
const result = await response.json();
alert(`Error: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Delete ignored unit completely (from emitters table)
async function deleteIgnoredUnit(unitId) {
if (!confirm(`Are you sure you want to PERMANENTLY delete unit ${unitId}?\n\nThis will remove it from the ignore list and delete all records.`)) {
return;
}
try {
// First remove from ignore list
await fetch(`/api/roster/ignore/${unitId}`, {
method: 'DELETE'
});
// Then delete the unit
const response = await fetch(`/api/roster/${unitId}`, {
method: 'DELETE'
});
if (response.ok) {
// Trigger ignored tab refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-ignored"]'), 'load');
alert(`Unit ${unitId} deleted successfully`);
} else {
const result = await response.json();
alert(`Error: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error: ${error.message}`);
}
}
</script> </script>
<style>
.roster-tab-button {
color: #6b7280; /* gray-500 */
border-bottom: 2px solid transparent;
}
.roster-tab-button:hover {
color: #374151; /* gray-700 */
}
.active-roster-tab {
color: #f48b1c !important; /* seismo orange */
border-bottom: 2px solid #f48b1c !important;
}
</style>
{% endblock %} {% endblock %}

584
templates/settings.html Normal file
View File

@@ -0,0 +1,584 @@
{% extends "base.html" %}
{% block title %}Settings - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Roster Manager</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage your fleet roster data - import, export, and reset</p>
</div>
<!-- CSV Export Section -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Export Roster</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Download all roster data as CSV for backup or editing externally
</p>
</div>
<div>
<a href="/api/settings/export-csv"
class="px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors inline-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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download CSV
</a>
</div>
</div>
</div>
<!-- CSV Import Section -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Import Roster</h2>
<form id="importSettingsForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSV File *</label>
<input type="file" name="file" accept=".csv" required
class="block w-full text-sm text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer bg-gray-50 dark:bg-slate-700 focus:outline-none">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
CSV must include column: unit_id (required)
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Import Mode *</label>
<div class="space-y-3">
<label class="flex items-start gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-slate-700 cursor-pointer">
<input type="radio" name="mode" value="merge" checked
class="mt-1 w-4 h-4 text-seismo-orange focus:ring-seismo-orange">
<div>
<div class="font-medium text-gray-900 dark:text-white">Merge/Overwrite</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Update existing units, add new units (safe)</div>
</div>
</label>
<label class="flex items-start gap-3 p-3 rounded-lg border border-red-300 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer">
<input type="radio" name="mode" value="replace"
class="mt-1 w-4 h-4 text-red-600 focus:ring-red-600">
<div>
<div class="font-medium text-red-600 dark:text-red-400">Replace All</div>
<div class="text-sm text-red-600 dark:text-red-400">⚠️ Delete ALL roster units first, then import (DANGEROUS)</div>
</div>
</label>
</div>
</div>
<div id="importResult" class="hidden"></div>
<button type="submit" class="px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
Import CSV
</button>
</form>
</div>
<!-- Roster Management Table -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Roster Units</h2>
<p class="text-gray-600 dark:text-gray-400 text-sm mt-1">
Manage all units with inline editing and quick actions
</p>
</div>
<button onclick="refreshRosterTable()" class="px-4 py-2 text-seismo-orange hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
</div>
<!-- Loading State -->
<div id="rosterTableLoading" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange"></div>
<p class="text-gray-600 dark:text-gray-400 mt-2">Loading roster units...</p>
</div>
<!-- Table Container -->
<div id="rosterTableContainer" class="hidden overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Unit ID</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Type</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Note</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Location</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Actions</th>
</tr>
</thead>
<tbody id="rosterTableBody" class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
<!-- Rows will be inserted here by JavaScript -->
</tbody>
</table>
</div>
<!-- Empty State -->
<div id="rosterTableEmpty" class="hidden text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p class="text-gray-600 dark:text-gray-400 mt-2">No roster units found</p>
</div>
</div>
<!-- Danger Zone -->
<div class="bg-red-50 dark:bg-red-900/20 rounded-xl border-2 border-red-200 dark:border-red-800 p-6">
<div class="flex items-start gap-3 mb-6">
<svg class="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<div>
<h2 class="text-xl font-semibold text-red-600 dark:text-red-400 mb-1">Danger Zone</h2>
<p class="text-sm text-gray-700 dark:text-gray-300">
Irreversible operations - use with extreme caution
</p>
</div>
</div>
<div class="space-y-4">
<!-- Clear All Data -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white dark:bg-slate-800 rounded-lg border border-red-200 dark:border-red-800">
<div class="flex-1">
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Clear All Data</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Delete ALL roster units, emitters, and ignored units
</p>
</div>
<button onclick="confirmClearAll()"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
Clear All
</button>
</div>
<!-- Clear Roster Only -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white dark:bg-slate-800 rounded-lg border border-red-200 dark:border-red-800">
<div class="flex-1">
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Clear Roster Table</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Delete all roster units only (keeps emitters and ignored units)
</p>
</div>
<button onclick="confirmClearRoster()"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
Clear Roster
</button>
</div>
<!-- Clear Emitters Only -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white dark:bg-slate-800 rounded-lg border border-red-200 dark:border-red-800">
<div class="flex-1">
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Clear Emitters Table</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Delete all auto-discovered emitters (will repopulate automatically)
</p>
</div>
<button onclick="confirmClearEmitters()"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
Clear Emitters
</button>
</div>
<!-- Clear Ignored Only -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white dark:bg-slate-800 rounded-lg border border-red-200 dark:border-red-800">
<div class="flex-1">
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Clear Ignored Units</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Remove all units from the ignore list
</p>
</div>
<button onclick="confirmClearIgnored()"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
Clear Ignored
</button>
</div>
</div>
</div>
<script>
// CSV Import Handler
document.getElementById('importSettingsForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const mode = formData.get('mode');
const resultDiv = document.getElementById('importResult');
// For replace mode, get confirmation
if (mode === 'replace') {
try {
const stats = await fetch('/api/settings/stats').then(r => r.json());
const confirmMsg = `⚠️ REPLACE MODE WARNING ⚠️
This will DELETE all ${stats.roster} roster units first, then import from CSV.
THIS CANNOT BE UNDONE!
Make sure you have a backup before proceeding.
Continue?`;
if (!confirm(confirmMsg)) {
return;
}
} catch (error) {
alert('Error fetching statistics: ' + error.message);
return;
}
}
// Choose endpoint based on mode
const endpoint = mode === 'replace'
? '/api/settings/import-csv-replace'
: '/api/roster/import-csv';
try {
const response = await fetch(endpoint, {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
resultDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
if (mode === 'replace') {
resultDiv.innerHTML = `
<p class="font-semibold mb-2">Import Successful!</p>
<ul class="text-sm space-y-1">
<li>✅ Deleted: ${result.deleted}</li>
<li>✅ Added: ${result.added}</li>
</ul>
`;
} else {
resultDiv.innerHTML = `
<p class="font-semibold mb-2">Import Successful!</p>
<ul class="text-sm space-y-1">
<li>✅ Added: ${result.summary.added}</li>
<li>🔄 Updated: ${result.summary.updated}</li>
<li>⏭️ Skipped: ${result.summary.skipped}</li>
<li>❌ Errors: ${result.summary.errors}</li>
</ul>
`;
}
resultDiv.classList.remove('hidden');
// Reset form after 3 seconds
setTimeout(() => {
this.reset();
resultDiv.classList.add('hidden');
}, 3000);
} else {
resultDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
resultDiv.innerHTML = `<p class="font-semibold">Import Failed</p><p class="text-sm">${result.detail || 'Unknown error'}</p>`;
resultDiv.classList.remove('hidden');
}
} catch (error) {
resultDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
resultDiv.innerHTML = `<p class="font-semibold">Import Failed</p><p class="text-sm">${error.message}</p>`;
resultDiv.classList.remove('hidden');
}
});
// Clear All Data
async function confirmClearAll() {
try {
const stats = await fetch('/api/settings/stats').then(r => r.json());
const message = `⚠️ CLEAR ALL DATA WARNING ⚠️
You are about to DELETE ALL data:
• Roster units: ${stats.roster}
• Emitters: ${stats.emitters}
• Ignored units: ${stats.ignored}
• TOTAL: ${stats.total} records
THIS ACTION CANNOT BE UNDONE!
Export a backup first if needed.
Are you absolutely sure?`;
if (!confirm(message)) {
return;
}
const response = await fetch('/api/settings/clear-all', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
alert(`✅ Success! Deleted ${result.deleted.total} records\n\n• Roster: ${result.deleted.roster}\n• Emitters: ${result.deleted.emitters}\n• Ignored: ${result.deleted.ignored}`);
location.reload();
} else {
const result = await response.json();
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
// Clear Roster Only
async function confirmClearRoster() {
try {
const stats = await fetch('/api/settings/stats').then(r => r.json());
if (!confirm(`Delete all ${stats.roster} roster units?\n\nThis cannot be undone!`)) {
return;
}
const response = await fetch('/api/settings/clear-roster', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
alert(`✅ Success! Deleted ${result.deleted} roster units`);
location.reload();
} else {
const result = await response.json();
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
// Clear Emitters Only
async function confirmClearEmitters() {
try {
const stats = await fetch('/api/settings/stats').then(r => r.json());
if (!confirm(`Delete all ${stats.emitters} emitters?\n\nEmitters will repopulate automatically from ACH files.`)) {
return;
}
const response = await fetch('/api/settings/clear-emitters', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
alert(`✅ Success! Deleted ${result.deleted} emitters`);
location.reload();
} else {
const result = await response.json();
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
// Clear Ignored Only
async function confirmClearIgnored() {
try {
const stats = await fetch('/api/settings/stats').then(r => r.json());
if (!confirm(`Remove all ${stats.ignored} units from ignore list?\n\nThey will appear as unknown emitters again.`)) {
return;
}
const response = await fetch('/api/settings/clear-ignored', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
alert(`✅ Success! Removed ${result.deleted} units from ignore list`);
location.reload();
} else {
const result = await response.json();
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
// ========== ROSTER MANAGEMENT TABLE ==========
// Load roster table on page load
document.addEventListener('DOMContentLoaded', function() {
loadRosterTable();
});
async function loadRosterTable() {
const loading = document.getElementById('rosterTableLoading');
const container = document.getElementById('rosterTableContainer');
const empty = document.getElementById('rosterTableEmpty');
const tbody = document.getElementById('rosterTableBody');
try {
loading.classList.remove('hidden');
container.classList.add('hidden');
empty.classList.add('hidden');
const response = await fetch('/api/settings/roster-units');
const units = await response.json();
if (units.length === 0) {
loading.classList.add('hidden');
empty.classList.remove('hidden');
return;
}
tbody.innerHTML = units.map(unit => createRosterRow(unit)).join('');
loading.classList.add('hidden');
container.classList.remove('hidden');
} catch (error) {
loading.classList.add('hidden');
alert('Error loading roster: ' + error.message);
}
}
function createRosterRow(unit) {
const statusBadges = [];
if (unit.deployed) {
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">Deployed</span>');
} else {
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">Benched</span>');
}
if (unit.retired) {
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300">Retired</span>');
}
return `
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" data-unit-id="${unit.id}">
<td class="px-4 py-3 whitespace-nowrap">
<a href="/unit/${unit.id}" class="text-sm font-medium text-seismo-orange hover:text-orange-600 dark:hover:text-orange-400">
${unit.id}
</a>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<div class="text-sm text-gray-900 dark:text-white">${unit.device_type}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">${unit.unit_type}</div>
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
${statusBadges.join('')}
</div>
</td>
<td class="px-4 py-3">
<div class="text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate" title="${unit.note}">
${unit.note || '<span class="text-gray-400 italic">No note</span>'}
</div>
</td>
<td class="px-4 py-3">
<div class="text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate" title="${unit.location}">
${unit.location || '<span class="text-gray-400 italic">—</span>'}
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
<div class="flex justify-end gap-1">
<button onclick="toggleDeployed('${unit.id}', ${unit.deployed})"
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
title="${unit.deployed ? 'Bench Unit' : 'Deploy Unit'}">
<svg class="w-4 h-4 ${unit.deployed ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</button>
<button onclick="toggleRetired('${unit.id}', ${unit.retired})"
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
title="${unit.retired ? 'Unretire Unit' : 'Retire Unit'}">
<svg class="w-4 h-4 ${unit.retired ? 'text-purple-600 dark:text-purple-400' : 'text-gray-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
</svg>
</button>
<button onclick="editUnit('${unit.id}')"
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors text-blue-600 dark:text-blue-400"
title="Edit Unit">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</button>
<button onclick="confirmDeleteUnit('${unit.id}')"
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors text-red-600 dark:text-red-400"
title="Delete Unit">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</td>
</tr>
`;
}
async function toggleDeployed(unitId, currentState) {
try {
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `deployed=${!currentState}`
});
if (response.ok) {
await loadRosterTable();
} else {
const result = await response.json();
alert('Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('Error: ' + error.message);
}
}
async function toggleRetired(unitId, currentState) {
try {
const response = await fetch(`/api/roster/set-retired/${unitId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `retired=${!currentState}`
});
if (response.ok) {
await loadRosterTable();
} else {
const result = await response.json();
alert('Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('Error: ' + error.message);
}
}
function editUnit(unitId) {
// Navigate to unit detail page for full editing
window.location.href = `/unit/${unitId}`;
}
async function confirmDeleteUnit(unitId) {
if (!confirm(`Delete unit ${unitId}?\n\nThis will permanently remove the unit from the roster.\n\nContinue?`)) {
return;
}
try {
const response = await fetch(`/api/roster/${unitId}`, {
method: 'DELETE'
});
if (response.ok) {
await loadRosterTable();
alert(`✅ Unit ${unitId} deleted successfully`);
} else {
const result = await response.json();
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
function refreshRosterTable() {
loadRosterTable();
}
</script>
{% endblock %}

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,590 @@
</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">
</div> <h1 class="text-3xl font-bold text-gray-900 dark:text-white" id="pageTitle">Loading...</h1>
<div class="flex gap-3">
<!-- Auto-refresh unit data --> <button id="editButton" onclick="enterEditMode()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
<div hx-get="/api/unit/{{ unit_id }}" hx-trigger="load, every 10s" hx-swap="none" hx-on::after-request="updateUnitData(event)"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <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>
<!-- Left Column: Unit Info --> </svg>
<div class="space-y-6"> Edit Unit
<!-- Status Card --> </button>
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6"> <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">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Status</h2> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="space-y-4"> <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>
<div class="flex items-center justify-between"> </svg>
<span class="text-gray-600 dark:text-gray-400">Status</span> Delete Unit
<div class="flex items-center space-x-2"> </button>
<span id="status-indicator" class="w-3 h-3 rounded-full bg-gray-400"></span>
<span id="status-text" class="font-semibold">Loading...</span>
</div>
</div>
<div class="flex items-center justify-between">
<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>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Unit Note
</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>
<button
type="button"
class="w-full px-4 py-2 bg-seismo-orange hover:bg-seismo-burgundy text-white rounded-lg font-medium transition-colors"
onclick="alert('Mock: Save functionality not implemented')">
Save Changes
</button>
</form>
</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>
</div> </div>
<!-- Loading state -->
<div id="loadingState" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-12 text-center">
<div class="animate-pulse">
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mx-auto mb-4"></div>
<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 -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="flex justify-between items-start mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Status</h2>
<div class="flex items-center space-x-2">
<span id="statusIndicator" class="w-3 h-3 rounded-full"></span>
<span id="statusText" class="font-semibold"></span>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<span class="text-sm text-gray-500 dark:text-gray-400">Last Seen</span>
<p id="lastSeen" class="font-medium text-gray-900 dark:text-white">--</p>
</div>
<div>
<span class="text-sm text-gray-500 dark:text-gray-400">Age</span>
<p id="age" class="font-medium text-gray-900 dark:text-white">--</p>
</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 hidden">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location</h2>
<div id="unit-map" style="height: 400px; width: 100%;" class="rounded-lg mb-4"></div>
<p id="locationText" class="text-sm text-gray-500 dark:text-gray-400"></p>
</div>
<!-- View Mode: Unit Information (Default) -->
<div id="viewMode" 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>
<div class="space-y-6">
<!-- Basic Info Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Device Type</label>
<p id="viewDeviceType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Unit Type</label>
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project ID</label>
<p id="viewProjectId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div class="md:col-span-2">
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Coordinates</label>
<p id="viewCoordinates" class="mt-1 text-gray-900 dark:text-white font-medium font-mono text-sm">--</p>
</div>
</div>
<!-- Seismograph Info -->
<div id="viewSeismographFields" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Seismograph Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Calibrated</label>
<p id="viewLastCalibrated" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Next Calibration Due</label>
<p id="viewNextCalibrationDue" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
<p id="viewDeployedWithModemId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
</div>
</div>
<!-- Modem Info -->
<div id="viewModemFields" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Modem Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">IP Address</label>
<p id="viewIpAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Phone Number</label>
<p id="viewPhoneNumber" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Hardware Model</label>
<p id="viewHardwareModel" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
</div>
</div>
<!-- Notes -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
</div>
</div>
</div>
<!-- Edit Mode: Unit Information Form (Hidden by default) -->
<div id="editMode" class="hidden rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Edit Unit Information</h2>
<button onclick="cancelEdit()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="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>
<!-- Address -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<input type="text" name="address" id="address" placeholder="123 Main St, City, State"
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>
<!-- Coordinates -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
<input type="text" name="coordinates" id="coordinates" placeholder="34.0522,-118.2437"
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 font-mono">
</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/Cancel Buttons -->
<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
</button>
<button type="button" onclick="cancelEdit()" 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
</button>
</div>
</form>
</div>
</div>
<script> <script>
let unitData = null; const unitId = "{{ unit_id }}";
let map = null; let currentUnit = null;
let marker = null; let currentSnapshot = null;
let unitMap = null;
let mapMarker = 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'); // Fetch unit roster data
btn.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400'); const rosterResponse = await fetch(`/api/roster/${unitId}`);
}); if (!rosterResponse.ok) {
document.getElementById(`tab-${tabName}`).classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400'); throw new Error('Unit not found');
document.getElementById(`tab-${tabName}`).classList.add('border-seismo-orange', 'text-seismo-orange'); }
currentUnit = await rosterResponse.json();
// Update tab content // Fetch snapshot data for status info
document.querySelectorAll('.tab-content').forEach(content => { const snapshotResponse = await fetch('/api/status-snapshot');
content.classList.add('hidden'); if (snapshotResponse.ok) {
}); currentSnapshot = await snapshotResponse.json();
document.getElementById(`content-${tabName}`).classList.remove('hidden'); }
// Initialize map if switching to map tab // Populate views
if (tabName === 'map' && !map && unitData) { populateViewMode();
setTimeout(() => initMap(), 100); populateEditForm();
// Hide loading, show content
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('mainContent').classList.remove('hidden');
// Initialize map after content is visible
setTimeout(() => {
initUnitMap();
}, 100);
} catch (error) {
alert(`Error loading unit: ${error.message}`);
window.location.href = '/roster';
} }
} }
function updateUnitData(event) { // Populate view mode (read-only display)
try { function populateViewMode() {
unitData = JSON.parse(event.detail.xhr.response); // Update page title
document.getElementById('pageTitle').textContent = `Unit ${currentUnit.id}`;
// Update status // Get status info from snapshot
const statusIndicator = document.getElementById('status-indicator'); let unitStatus = null;
const statusText = document.getElementById('status-text'); if (currentSnapshot && currentSnapshot.units) {
unitStatus = currentSnapshot.units[unitId];
}
// Status card
if (unitStatus) {
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'}`; const statusTextColors = {
statusText.textContent = unitData.status; 'OK': 'text-green-600 dark:text-green-400',
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'}`; 'Pending': 'text-yellow-600 dark:text-yellow-400',
'Missing': 'text-red-600 dark:text-red-400'
};
// Update other fields document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors[unitStatus.status] || 'bg-gray-400'}`;
document.getElementById('deployed-status').textContent = unitData.deployed ? '✓ Deployed' : '✗ Benched'; document.getElementById('statusText').className = `font-semibold ${statusTextColors[unitStatus.status] || 'text-gray-600'}`;
document.getElementById('age-value').textContent = unitData.age; document.getElementById('statusText').textContent = unitStatus.status || 'Unknown';
document.getElementById('last-seen-value').textContent = unitData.last_seen; document.getElementById('lastSeen').textContent = unitStatus.last || '--';
document.getElementById('last-file-value').textContent = unitData.last_file; document.getElementById('age').textContent = unitStatus.age || '--';
document.getElementById('notes-content').textContent = unitData.note || 'No notes available'; } else {
document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400';
document.getElementById('statusText').className = 'font-semibold text-gray-600 dark:text-gray-400';
document.getElementById('statusText').textContent = 'No status data';
document.getElementById('lastSeen').textContent = '--';
document.getElementById('age').textContent = '--';
}
// Update location info document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
if (unitData.coordinates) { document.getElementById('retiredStatus').textContent = currentUnit.retired ? 'Yes' : 'No';
document.getElementById('location-name').textContent = unitData.coordinates.location;
document.getElementById('coordinates').textContent = `${unitData.coordinates.lat.toFixed(4)}, ${unitData.coordinates.lon.toFixed(4)}`;
}
} catch (error) { // Basic info
console.error('Error updating unit data:', error); document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
document.getElementById('viewProjectId').textContent = currentUnit.project_id || '--';
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
// Seismograph fields
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
document.getElementById('viewDeployedWithModemId').textContent = currentUnit.deployed_with_modem_id || '--';
// Modem fields
document.getElementById('viewIpAddress').textContent = currentUnit.ip_address || '--';
document.getElementById('viewPhoneNumber').textContent = currentUnit.phone_number || '--';
document.getElementById('viewHardwareModel').textContent = currentUnit.hardware_model || '--';
// Notes
document.getElementById('viewNote').textContent = currentUnit.note || '--';
// Show/hide fields based on device type
if (currentUnit.device_type === 'modem') {
document.getElementById('viewSeismographFields').classList.add('hidden');
document.getElementById('viewModemFields').classList.remove('hidden');
} else {
document.getElementById('viewSeismographFields').classList.remove('hidden');
document.getElementById('viewModemFields').classList.add('hidden');
} }
} }
function updatePhotos(event) { // Populate edit form
function populateEditForm() {
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
document.getElementById('unitType').value = currentUnit.unit_type || '';
document.getElementById('projectId').value = currentUnit.project_id || '';
document.getElementById('address').value = currentUnit.address || '';
document.getElementById('coordinates').value = currentUnit.coordinates || '';
document.getElementById('deployed').checked = currentUnit.deployed;
document.getElementById('retired').checked = currentUnit.retired;
document.getElementById('note').value = currentUnit.note || '';
// Seismograph fields
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || '';
document.getElementById('deployedWithModemId').value = currentUnit.deployed_with_modem_id || '';
// Modem fields
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();
}
// 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');
}
}
// Enter edit mode
function enterEditMode() {
document.getElementById('viewMode').classList.add('hidden');
document.getElementById('editMode').classList.remove('hidden');
document.getElementById('editButton').classList.add('hidden');
}
// Cancel edit mode
function cancelEdit() {
document.getElementById('editMode').classList.add('hidden');
document.getElementById('viewMode').classList.remove('hidden');
document.getElementById('editButton').classList.remove('hidden');
// Reset form to current values
populateEditForm();
}
// 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) { if (response.ok) {
container.innerHTML = ` alert('Unit updated successfully!');
<div class="text-center text-gray-500 dark:text-gray-400 py-12"> // Reload data and return to view mode
<svg class="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"> await loadUnitData();
<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> cancelEdit();
</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 { } else {
let html = ` const result = await response.json();
<div class="mb-4"> alert(`Error: ${result.detail || 'Unknown error'}`);
<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>';
container.innerHTML = html;
} }
} 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 (only called once)
if (!unitData || !unitData.coordinates) return; function initUnitMap() {
if (!currentUnit.coordinates) {
document.getElementById('mapCard').classList.add('hidden');
return;
}
const coords = unitData.coordinates; const coords = parseLocation(currentUnit.coordinates);
map = L.map('map').setView([coords.lat, coords.lon], 13); if (!coords) {
document.getElementById('mapCard').classList.add('hidden');
return;
}
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { const [lat, lon] = coords;
attribution: '© OpenStreetMap contributors'
}).addTo(map);
marker = L.marker([coords.lat, coords.lon]).addTo(map) // Show the map card
.bindPopup(`<b>${unitData.id}</b><br>${coords.location}`) document.getElementById('mapCard').classList.remove('hidden');
.openPopup();
// Only initialize map if it doesn't exist
if (!unitMap) {
// Initialize map
unitMap = L.map('unit-map').setView([lat, lon], 13);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18
}).addTo(unitMap);
// Force map to update its size
setTimeout(() => {
unitMap.invalidateSize();
}, 100);
}
// Update marker (can be called multiple times)
updateMapMarker(lat, lon);
// Update location text
const locationParts = [];
if (currentUnit.address) {
locationParts.push(currentUnit.address);
}
locationParts.push(`Coordinates: ${lat.toFixed(6)}, ${lon.toFixed(6)}`);
document.getElementById('locationText').textContent = locationParts.join(' • ');
} }
// Update map marker with current status
function updateMapMarker(lat, lon) {
// Remove old marker if it exists
if (mapMarker) {
mapMarker.remove();
}
// Get status color
let statusColor = 'gray';
let status = 'Unknown';
if (currentSnapshot && currentSnapshot.units && currentSnapshot.units[unitId]) {
const unitStatus = currentSnapshot.units[unitId];
status = unitStatus.status || 'Unknown';
statusColor = status === 'OK' ? 'green' : status === 'Pending' ? 'orange' : status === 'Missing' ? 'red' : 'gray';
}
// Add new marker
mapMarker = 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">${currentUnit.id}</h3>
<p class="text-sm">Status: <span style="color: ${statusColor}">${status}</span></p>
<p class="text-sm">Type: ${currentUnit.device_type}</p>
</div>
`).openPopup();
}
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) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
return [lat, lon];
}
}
return null;
}
// Load data when page loads
loadUnitData();
</script> </script>
{% endblock %} {% endblock %}