v0.2.1. many features added and cleaned up.

This commit is contained in:
serversdwn
2025-12-03 21:23:18 +00:00
parent dc853806bb
commit 4cef580185
13 changed files with 1815 additions and 181 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"],
@@ -105,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

@@ -31,7 +31,9 @@ class RosterUnit(Base):
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) # Seismograph-specific fields (nullable for modems)

View File

@@ -5,7 +5,7 @@ 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"])
@@ -26,9 +26,12 @@ def add_roster_unit(
device_type: str = Form("seismograph"), 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 # Seismograph-specific fields
last_calibrated: str = Form(None), last_calibrated: str = Form(None),
next_calibration_due: str = Form(None), next_calibration_due: str = Form(None),
@@ -62,9 +65,12 @@ def add_roster_unit(
device_type=device_type, 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 # Seismograph-specific fields
last_calibrated=last_cal_date, last_calibrated=last_cal_date,
@@ -96,6 +102,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"note": unit.note or "", "note": unit.note or "",
"project_id": unit.project_id or "", "project_id": unit.project_id or "",
"location": unit.location or "", "location": unit.location or "",
"address": unit.address or "",
"coordinates": unit.coordinates or "",
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "", "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 "", "next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
"deployed_with_modem_id": unit.deployed_with_modem_id or "", "deployed_with_modem_id": unit.deployed_with_modem_id or "",
@@ -115,6 +123,8 @@ def edit_roster_unit(
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 # Seismograph-specific fields
last_calibrated: str = Form(None), last_calibrated: str = Form(None),
next_calibration_due: str = Form(None), next_calibration_due: str = Form(None),
@@ -152,6 +162,8 @@ def edit_roster_unit(
unit.note = note unit.note = note
unit.project_id = project_id unit.project_id = project_id
unit.location = location unit.location = location
unit.address = address
unit.coordinates = coordinates
unit.last_updated = datetime.utcnow() unit.last_updated = datetime.utcnow()
# Seismograph-specific fields # Seismograph-specific fields
@@ -189,14 +201,27 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g
@router.delete("/{unit_id}") @router.delete("/{unit_id}")
def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)): def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
""" """
Permanently delete a unit from the roster database. Permanently delete a unit from the database.
This is different from ignoring - the unit is completely removed. Checks both roster and emitters tables and deletes from any table where the unit exists.
""" """
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() deleted = False
if not unit:
# 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") raise HTTPException(status_code=404, detail="Unit not found")
db.delete(unit)
db.commit() db.commit()
return {"message": "Unit deleted", "id": unit_id} return {"message": "Unit deleted", "id": unit_id}
@@ -274,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)
@@ -287,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

@@ -79,6 +79,8 @@ def emit_status_snapshot():
"hardware_model": r.hardware_model, "hardware_model": r.hardware_model,
# Location for mapping # Location for mapping
"location": r.location or "", "location": r.location or "",
"address": r.address or "",
"coordinates": r.coordinates or "",
} }
# --- Add unexpected emitter-only units --- # --- Add unexpected emitter-only units ---
@@ -102,6 +104,10 @@ def emit_status_snapshot():
"ip_address": None, "ip_address": None,
"phone_number": None, "phone_number": None,
"hardware_model": None, "hardware_model": None,
# Location fields
"location": "",
"address": "",
"coordinates": "",
} }
# Separate buckets for UI # Separate buckets for UI
@@ -139,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:

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>
@@ -176,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 =
@@ -243,6 +250,7 @@ document.addEventListener('DOMContentLoaded', function() {
let fleetMap = null; let fleetMap = null;
let fleetMarkers = []; let fleetMarkers = [];
let fleetMapInitialized = false;
function initFleetMap() { function initFleetMap() {
// Initialize the map centered on the US (can adjust based on your deployment area) // Initialize the map centered on the US (can adjust based on your deployment area)
@@ -262,8 +270,8 @@ function updateFleetMap(data) {
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker)); fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
fleetMarkers = []; fleetMarkers = [];
// Get deployed units with location data // Get deployed units with coordinates data
const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.location); const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.coordinates);
if (deployedUnits.length === 0) { if (deployedUnits.length === 0) {
return; return;
@@ -272,7 +280,7 @@ function updateFleetMap(data) {
const bounds = []; const bounds = [];
deployedUnits.forEach(([id, unit]) => { deployedUnits.forEach(([id, unit]) => {
const coords = parseLocation(unit.location); const coords = parseLocation(unit.coordinates);
if (coords) { if (coords) {
const [lat, lon] = coords; const [lat, lon] = coords;
@@ -304,9 +312,10 @@ function updateFleetMap(data) {
} }
}); });
// Fit map to show all markers // Fit map to show all markers only on first load
if (bounds.length > 0) { if (bounds.length > 0 && !fleetMapInitialized) {
fleetMap.fitBounds(bounds, { padding: [50, 50] }); fleetMap.fitBounds(bounds, { padding: [50, 50] });
fleetMapInitialized = true;
} }
} }

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,36 +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>
<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('id')">
Unit ID <div class="flex items-center gap-1">
Unit ID
<span class="sort-indicator" data-column="id"></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('type')">
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">
Details 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' %}
@@ -159,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 -->
@@ -143,6 +177,11 @@
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>
@@ -175,7 +214,7 @@
</button> </button>
</div> </div>
</div> </div>
<form id="editUnitForm" hx-post="" hx-swap="none" class="p-6 space-y-4"> <form id="editUnitForm" class="p-6 space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit ID</label> <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 <input type="text" name="id" id="editUnitId" readonly
@@ -200,10 +239,15 @@
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">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<input type="text" name="location" id="editLocation" <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"> 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>
<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 --> <!-- Seismograph-specific fields -->
<div id="editSeismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4"> <div id="editSeismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
@@ -406,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 {
@@ -469,7 +516,8 @@
document.getElementById('editDeviceTypeSelect').value = unit.device_type; document.getElementById('editDeviceTypeSelect').value = unit.device_type;
document.getElementById('editUnitType').value = unit.unit_type; document.getElementById('editUnitType').value = unit.unit_type;
document.getElementById('editProjectId').value = unit.project_id; document.getElementById('editProjectId').value = unit.project_id;
document.getElementById('editLocation').value = unit.location; document.getElementById('editAddress').value = unit.address;
document.getElementById('editCoordinates').value = unit.coordinates;
document.getElementById('editNote').value = unit.note; document.getElementById('editNote').value = unit.note;
// Checkboxes // Checkboxes
@@ -486,8 +534,8 @@
document.getElementById('editPhoneNumber').value = unit.phone_number; document.getElementById('editPhoneNumber').value = unit.phone_number;
document.getElementById('editHardwareModel').value = unit.hardware_model; document.getElementById('editHardwareModel').value = unit.hardware_model;
// Set form action // Store unit ID for form submission
document.getElementById('editUnitForm').setAttribute('hx-post', `/api/roster/edit/${unitId}`); document.getElementById('editUnitForm').dataset.unitId = unitId;
// Show/hide fields based on device type // Show/hide fields based on device type
toggleEditDeviceFields(); toggleEditDeviceFields();
@@ -500,14 +548,37 @@
} }
// Handle Edit Unit form submission // Handle Edit Unit form submission
document.getElementById('editUnitForm').addEventListener('htmx:afterRequest', function(event) { document.getElementById('editUnitForm').addEventListener('submit', async function(event) {
if (event.detail.successful) { event.preventDefault();
closeEditUnitModal();
// Trigger roster refresh const unitId = this.dataset.unitId;
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load'); if (!unitId) {
alert('Unit updated successfully!'); alert('Error: Unit ID not found');
} else { return;
alert('Error updating unit. Please check the form and try again.'); }
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}`);
} }
}); });
@@ -528,8 +599,11 @@
}); });
if (response.ok) { if (response.ok) {
// 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'
});
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`); alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
} else { } else {
const result = await response.json(); const result = await response.json();
@@ -557,8 +631,11 @@
}); });
if (response.ok) { if (response.ok) {
// 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'
});
alert(`Unit ${unitId} moved to ignore list`); alert(`Unit ${unitId} moved to ignore list`);
} else { } else {
const result = await response.json(); const result = await response.json();
@@ -581,8 +658,11 @@
}); });
if (response.ok) { if (response.ok) {
// 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'
});
alert(`Unit ${unitId} deleted successfully`); alert(`Unit ${unitId} deleted successfully`);
} else { } else {
const result = await response.json(); const result = await response.json();
@@ -621,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);
@@ -637,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

@@ -12,12 +12,20 @@
</a> </a>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white" id="pageTitle">Loading...</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-white" id="pageTitle">Loading...</h1>
<button onclick="deleteUnit()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-center gap-2"> <div class="flex gap-3">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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">
<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 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>
Delete Unit </svg>
</button> Edit Unit
</button>
<button onclick="deleteUnit()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Delete Unit
</button>
</div>
</div> </div>
</div> </div>
@@ -61,15 +69,98 @@
</div> </div>
<!-- Location Map --> <!-- Location Map -->
<div id="mapCard" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6"> <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> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location</h2>
<div id="unit-map" class="w-full h-64 rounded-lg mb-4"></div> <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> <p id="locationText" class="text-sm text-gray-500 dark:text-gray-400"></p>
</div> </div>
<!-- Edit Unit Form --> <!-- View Mode: Unit Information (Default) -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6"> <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> <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"> <form id="editForm" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Device Type --> <!-- Device Type -->
@@ -96,12 +187,19 @@
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">
</div> </div>
<!-- Location --> <!-- Address -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<input type="text" name="location" id="location" <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"> 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>
<!-- 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> </div>
<!-- Seismograph Fields --> <!-- Seismograph Fields -->
@@ -169,14 +267,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"></textarea> 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>
<!-- Save Button --> <!-- Save/Cancel Buttons -->
<div class="flex gap-3"> <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"> <button type="submit" class="flex-1 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
Save Changes Save Changes
</button> </button>
<a href="/roster" class="px-6 py-3 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg font-medium transition-colors"> <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 Cancel
</a> </button>
</div> </div>
</form> </form>
</div> </div>
@@ -185,69 +283,137 @@
<script> <script>
const unitId = "{{ unit_id }}"; const unitId = "{{ unit_id }}";
let currentUnit = null; let currentUnit = null;
let currentSnapshot = null;
let unitMap = null;
let mapMarker = null;
// Load unit data on page load // Load unit data on page load
async function loadUnitData() { async function loadUnitData() {
try { try {
const response = await fetch(`/api/roster/${unitId}`); // Fetch unit roster data
if (!response.ok) { const rosterResponse = await fetch(`/api/roster/${unitId}`);
if (!rosterResponse.ok) {
throw new Error('Unit not found'); throw new Error('Unit not found');
} }
currentUnit = await rosterResponse.json();
currentUnit = await response.json(); // Fetch snapshot data for status info
populateForm(); const snapshotResponse = await fetch('/api/status-snapshot');
if (snapshotResponse.ok) {
currentSnapshot = await snapshotResponse.json();
}
// Populate views
populateViewMode();
populateEditForm();
// Hide loading, show content // Hide loading, show content
document.getElementById('loadingState').classList.add('hidden'); document.getElementById('loadingState').classList.add('hidden');
document.getElementById('mainContent').classList.remove('hidden'); document.getElementById('mainContent').classList.remove('hidden');
// Initialize map after content is visible
setTimeout(() => {
initUnitMap();
}, 100);
} catch (error) { } catch (error) {
alert(`Error loading unit: ${error.message}`); alert(`Error loading unit: ${error.message}`);
window.location.href = '/roster'; window.location.href = '/roster';
} }
} }
// Populate form with unit data // Populate view mode (read-only display)
function populateForm() { function populateViewMode() {
// Update page title // Update page title
document.getElementById('pageTitle').textContent = `Unit ${currentUnit.id}`; document.getElementById('pageTitle').textContent = `Unit ${currentUnit.id}`;
// Status info // Get status info from snapshot
const statusColors = { let unitStatus = null;
'OK': 'bg-green-500', if (currentSnapshot && currentSnapshot.units) {
'Pending': 'bg-yellow-500', unitStatus = currentSnapshot.units[unitId];
'Missing': 'bg-red-500' }
};
document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors.OK || 'bg-gray-400'}`; // Status card
document.getElementById('statusText').textContent = 'No status data'; if (unitStatus) {
document.getElementById('lastSeen').textContent = '--'; const statusColors = {
document.getElementById('age').textContent = '--'; 'OK': 'bg-green-500',
'Pending': 'bg-yellow-500',
'Missing': 'bg-red-500'
};
const statusTextColors = {
'OK': 'text-green-600 dark:text-green-400',
'Pending': 'text-yellow-600 dark:text-yellow-400',
'Missing': 'text-red-600 dark:text-red-400'
};
document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors[unitStatus.status] || 'bg-gray-400'}`;
document.getElementById('statusText').className = `font-semibold ${statusTextColors[unitStatus.status] || 'text-gray-600'}`;
document.getElementById('statusText').textContent = unitStatus.status || 'Unknown';
document.getElementById('lastSeen').textContent = unitStatus.last || '--';
document.getElementById('age').textContent = unitStatus.age || '--';
} 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 = '--';
}
document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No'; document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
document.getElementById('retiredStatus').textContent = currentUnit.retired ? 'Yes' : 'No'; document.getElementById('retiredStatus').textContent = currentUnit.retired ? 'Yes' : 'No';
// Form fields // Basic info
document.getElementById('deviceType').value = currentUnit.device_type; document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
document.getElementById('unitType').value = currentUnit.unit_type; document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
document.getElementById('projectId').value = currentUnit.project_id; document.getElementById('viewProjectId').textContent = currentUnit.project_id || '--';
document.getElementById('location').value = currentUnit.location; document.getElementById('viewAddress').textContent = currentUnit.address || '--';
document.getElementById('deployed').checked = currentUnit.deployed; document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
document.getElementById('retired').checked = currentUnit.retired;
document.getElementById('note').value = currentUnit.note;
// Seismograph fields // Seismograph fields
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated; document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due; document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
document.getElementById('deployedWithModemId').value = currentUnit.deployed_with_modem_id; document.getElementById('viewDeployedWithModemId').textContent = currentUnit.deployed_with_modem_id || '--';
// Modem fields // Modem fields
document.getElementById('ipAddress').value = currentUnit.ip_address; document.getElementById('viewIpAddress').textContent = currentUnit.ip_address || '--';
document.getElementById('phoneNumber').value = currentUnit.phone_number; document.getElementById('viewPhoneNumber').textContent = currentUnit.phone_number || '--';
document.getElementById('hardwareModel').value = currentUnit.hardware_model; 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');
}
}
// 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 // Show/hide fields based on device type
toggleDetailFields(); toggleDetailFields();
// Update map with unit location
updateUnitMap(currentUnit);
} }
// Toggle device-specific fields // Toggle device-specific fields
@@ -265,6 +431,23 @@ function toggleDetailFields() {
} }
} }
// 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 // Handle form submission
document.getElementById('editForm').addEventListener('submit', async function(e) { document.getElementById('editForm').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
@@ -279,7 +462,9 @@ document.getElementById('editForm').addEventListener('submit', async function(e)
if (response.ok) { if (response.ok) {
alert('Unit updated successfully!'); alert('Unit updated successfully!');
loadUnitData(); // Reload data // Reload data and return to view mode
await loadUnitData();
cancelEdit();
} else { } else {
const result = await response.json(); const result = await response.json();
alert(`Error: ${result.detail || 'Unknown error'}`); alert(`Error: ${result.detail || 'Unknown error'}`);
@@ -312,61 +497,84 @@ async function deleteUnit() {
} }
} }
// Initialize unit location map // Initialize unit location map (only called once)
let unitMap = null;
function initUnitMap() { function initUnitMap() {
// Default center (will be updated when location is loaded) if (!currentUnit.coordinates) {
unitMap = L.map('unit-map').setView([39.8283, -98.5795], 4);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 18
}).addTo(unitMap);
}
function updateUnitMap(unit) {
if (!unit.location || !unitMap) {
document.getElementById('mapCard').classList.add('hidden'); document.getElementById('mapCard').classList.add('hidden');
return; return;
} }
const coords = parseLocation(unit.location); const coords = parseLocation(currentUnit.coordinates);
if (coords) { if (!coords) {
const [lat, lon] = coords; document.getElementById('mapCard').classList.add('hidden');
return;
// Show the map card
document.getElementById('mapCard').classList.remove('hidden');
// Center map on unit location
unitMap.setView([lat, lon], 13);
// Add marker with unit info
const statusColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'orange' : 'red';
L.circleMarker([lat, lon], {
radius: 10,
fillColor: statusColor,
color: '#fff',
weight: 3,
opacity: 1,
fillOpacity: 0.8
}).addTo(unitMap).bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg">${unit.id}</h3>
<p class="text-sm">Status: <span style="color: ${statusColor}">${unit.status || 'Unknown'}</span></p>
<p class="text-sm">Type: ${unit.device_type}</p>
</div>
`).openPopup();
// Update location text
document.getElementById('locationText').textContent = `Coordinates: ${lat.toFixed(6)}, ${lon.toFixed(6)}`;
} else {
// Show map card but indicate location not mappable
document.getElementById('mapCard').classList.remove('hidden');
document.getElementById('locationText').textContent = `Location: ${unit.location} (coordinates not available)`;
} }
const [lat, lon] = coords;
// Show the map card
document.getElementById('mapCard').classList.remove('hidden');
// 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) { function parseLocation(location) {
@@ -377,17 +585,15 @@ function parseLocation(location) {
if (parts.length === 2) { if (parts.length === 2) {
const lat = parseFloat(parts[0]); const lat = parseFloat(parts[0]);
const lon = parseFloat(parts[1]); const lon = parseFloat(parts[1]);
if (!isNaN(lat) && !isNaN(lon)) { if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
return [lat, lon]; return [lat, lon];
} }
} }
// TODO: Add geocoding support for address strings
return null; return null;
} }
// Load data when page loads // Load data when page loads
initUnitMap();
loadUnitData(); loadUnitData();
</script> </script>
{% endblock %} {% endblock %}