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

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

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.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
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.services.snapshot import emit_status_snapshot
from backend.models import IgnoredUnit
# Create database tables
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_tabs.router)
from backend.routers import settings
app.include_router(settings.router)
# 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)
async def roster_table_partial(request: Request):
"""Partial template for roster table (HTMX)"""
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
"""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
snapshot = emit_status_snapshot()
units_list = []
for unit_id, unit_data in snapshot["units"].items():
for unit_id, unit_data in snapshot["active"].items():
units_list.append({
"id": unit_id,
"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)
async def unknown_emitters_partial(request: Request):
"""Partial template for unknown emitters (HTMX)"""

View File

@@ -31,7 +31,9 @@ class RosterUnit(Base):
retired = Column(Boolean, default=False)
note = 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)
# Seismograph-specific fields (nullable for modems)

View File

@@ -5,7 +5,7 @@ import csv
import io
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"])
@@ -26,9 +26,12 @@ def add_roster_unit(
device_type: str = Form("seismograph"),
unit_type: str = Form("series3"),
deployed: bool = Form(False),
retired: bool = Form(False),
note: str = Form(""),
project_id: str = Form(None),
location: str = Form(None),
address: str = Form(None),
coordinates: str = Form(None),
# Seismograph-specific fields
last_calibrated: str = Form(None),
next_calibration_due: str = Form(None),
@@ -62,9 +65,12 @@ def add_roster_unit(
device_type=device_type,
unit_type=unit_type,
deployed=deployed,
retired=retired,
note=note,
project_id=project_id,
location=location,
address=address,
coordinates=coordinates,
last_updated=datetime.utcnow(),
# Seismograph-specific fields
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 "",
"project_id": unit.project_id or "",
"location": unit.location or "",
"address": unit.address or "",
"coordinates": unit.coordinates or "",
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
@@ -115,6 +123,8 @@ def edit_roster_unit(
note: str = Form(""),
project_id: str = Form(None),
location: str = Form(None),
address: str = Form(None),
coordinates: str = Form(None),
# Seismograph-specific fields
last_calibrated: str = Form(None),
next_calibration_due: str = Form(None),
@@ -152,6 +162,8 @@ def edit_roster_unit(
unit.note = note
unit.project_id = project_id
unit.location = location
unit.address = address
unit.coordinates = coordinates
unit.last_updated = datetime.utcnow()
# Seismograph-specific fields
@@ -189,14 +201,27 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g
@router.delete("/{unit_id}")
def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"""
Permanently delete a unit from the roster database.
This is different from ignoring - the unit is completely removed.
Permanently delete a unit from the database.
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()
if not unit:
deleted = False
# Try to delete from roster table
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if roster_unit:
db.delete(roster_unit)
deleted = True
# Try to delete from emitters table
emitter = db.query(Emitter).filter(Emitter.id == unit_id).first()
if emitter:
db.delete(emitter)
deleted = True
# If not found in either table, return error
if not deleted:
raise HTTPException(status_code=404, detail="Unit not found")
db.delete(unit)
db.commit()
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.project_id = row.get('project_id', existing_unit.project_id)
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()
results["updated"].append(unit_id)
@@ -287,6 +314,8 @@ async def import_csv(
note=row.get('note', ''),
project_id=row.get('project_id'),
location=row.get('location'),
address=row.get('address'),
coordinates=row.get('coordinates'),
last_updated=datetime.utcnow()
)
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,
# Location for mapping
"location": r.location or "",
"address": r.address or "",
"coordinates": r.coordinates or "",
}
# --- Add unexpected emitter-only units ---
@@ -102,6 +104,10 @@ def emit_status_snapshot():
"ip_address": None,
"phone_number": None,
"hardware_model": None,
# Location fields
"location": "",
"address": "",
"coordinates": "",
}
# Separate buckets for UI
@@ -139,9 +145,10 @@ def emit_status_snapshot():
"benched": len(benched_units),
"retired": len(retired_units),
"unknown": len(unknown_units),
"ok": sum(1 for u in units.values() if u["status"] == "OK"),
"pending": sum(1 for u in units.values() if u["status"] == "Pending"),
"missing": sum(1 for u in units.values() if u["status"] == "Missing"),
# Status counts only for deployed units (active_units)
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
"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:

View File

@@ -68,7 +68,7 @@
Seismo<br>
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
</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>
<!-- Navigation -->
@@ -94,7 +94,7 @@
Projects
</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">
<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>

View File

@@ -35,7 +35,12 @@
<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>
</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">
<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 items-center">
<span class="w-3 h-3 rounded-full bg-green-500 mr-2"></span>
@@ -176,14 +181,16 @@ function updateDashboard(event) {
// ===== Fleet summary numbers =====
document.getElementById('total-units').textContent = data.summary?.total ?? 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-pending').textContent = data.summary?.pending ?? 0;
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
// ===== Alerts =====
const alertsList = document.getElementById('alerts-list');
const missingUnits = Object.entries(data.units).filter(([_, u]) => u.status === 'Missing');
const pendingUnits = Object.entries(data.units).filter(([_, u]) => u.status === 'Pending');
// Only show alerts for deployed units (not benched)
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) {
alertsList.innerHTML =
@@ -243,6 +250,7 @@ document.addEventListener('DOMContentLoaded', function() {
let fleetMap = null;
let fleetMarkers = [];
let fleetMapInitialized = false;
function initFleetMap() {
// 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 = [];
// Get deployed units with location data
const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.location);
// Get deployed units with coordinates data
const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.coordinates);
if (deployedUnits.length === 0) {
return;
@@ -272,7 +280,7 @@ function updateFleetMap(data) {
const bounds = [];
deployedUnits.forEach(([id, unit]) => {
const coords = parseLocation(unit.location);
const coords = parseLocation(unit.coordinates);
if (coords) {
const [lat, lon] = coords;
@@ -304,9 +312,10 @@ function updateFleetMap(data) {
}
});
// Fit map to show all markers
if (bounds.length > 0) {
// Fit map to show all markers only on first load
if (bounds.length > 0 && !fleetMapInitialized) {
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">
<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">
<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')">
<div class="flex items-center gap-1">
Status
<span class="sort-indicator" data-column="status"></span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('id')">
<div class="flex items-center gap-1">
Unit ID
<span class="sort-indicator" data-column="id"></span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('type')">
<div class="flex items-center gap-1">
Type
<span class="sort-indicator" data-column="type"></span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Details
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('last_seen')">
<div class="flex items-center gap-1">
Last Seen
<span class="sort-indicator" data-column="last_seen"></span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<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')">
<div class="flex items-center gap-1">
Age
<span class="sort-indicator" data-column="age"></span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<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')">
<div class="flex items-center gap-1">
Note
<span class="sort-indicator" data-column="note"></span>
</div>
</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">
<tbody id="roster-tbody" class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
{% 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">
<div class="flex items-center space-x-2">
{% if unit.status == 'OK' %}
@@ -159,7 +183,108 @@
</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>
// Update timestamp
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>

View File

@@ -31,22 +31,56 @@
<!-- Loading placeholder -->
</div>
<!-- Auto-refresh roster every 10 seconds -->
<div hx-get="/partials/roster-table" hx-trigger="load, every 10s" hx-swap="innerHTML">
<!-- Initial loading state -->
<!-- Fleet Roster with Tabs -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-center py-12">
<div class="animate-pulse flex space-x-4">
<div class="flex-1 space-y-4 py-1">
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div>
<div class="space-y-2">
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-5/6"></div>
</div>
</div>
</div>
<!-- Tab Bar -->
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
<button
class="px-4 py-2 text-sm font-medium roster-tab-button active-roster-tab"
data-endpoint="/partials/roster-deployed"
hx-get="/partials/roster-deployed"
hx-target="#roster-content"
hx-swap="innerHTML">
Deployed
</button>
<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>
<!-- 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>
<!-- Add Unit Modal -->
@@ -143,6 +177,11 @@
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="retired" id="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>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
@@ -175,7 +214,7 @@
</button>
</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>
<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
@@ -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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
<input type="text" name="location" id="editLocation"
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<input type="text" name="address" id="editAddress" placeholder="123 Main St, City, State"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
<input type="text" name="coordinates" id="editCoordinates" placeholder="34.0522,-118.2437"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange font-mono">
</div>
<!-- Seismograph-specific fields -->
<div id="editSeismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
@@ -406,8 +450,11 @@
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
closeAddUnitModal();
// Trigger roster refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
// Show success message
alert('Unit added successfully!');
} else {
@@ -469,7 +516,8 @@
document.getElementById('editDeviceTypeSelect').value = unit.device_type;
document.getElementById('editUnitType').value = unit.unit_type;
document.getElementById('editProjectId').value = unit.project_id;
document.getElementById('editLocation').value = unit.location;
document.getElementById('editAddress').value = unit.address;
document.getElementById('editCoordinates').value = unit.coordinates;
document.getElementById('editNote').value = unit.note;
// Checkboxes
@@ -486,8 +534,8 @@
document.getElementById('editPhoneNumber').value = unit.phone_number;
document.getElementById('editHardwareModel').value = unit.hardware_model;
// Set form action
document.getElementById('editUnitForm').setAttribute('hx-post', `/api/roster/edit/${unitId}`);
// Store unit ID for form submission
document.getElementById('editUnitForm').dataset.unitId = unitId;
// Show/hide fields based on device type
toggleEditDeviceFields();
@@ -500,14 +548,37 @@
}
// Handle Edit Unit form submission
document.getElementById('editUnitForm').addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
document.getElementById('editUnitForm').addEventListener('submit', async function(event) {
event.preventDefault();
const unitId = this.dataset.unitId;
if (!unitId) {
alert('Error: Unit ID not found');
return;
}
const formData = new FormData(this);
try {
const response = await fetch(`/api/roster/edit/${unitId}`, {
method: 'POST',
body: formData
});
if (response.ok) {
closeEditUnitModal();
// Trigger roster refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
alert('Unit updated successfully!');
} else {
alert('Error updating unit. Please check the form and try again.');
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) {
// Trigger roster refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
} else {
const result = await response.json();
@@ -557,8 +631,11 @@
});
if (response.ok) {
// Trigger roster refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
alert(`Unit ${unitId} moved to ignore list`);
} else {
const result = await response.json();
@@ -581,8 +658,11 @@
});
if (response.ok) {
// Trigger roster refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
alert(`Unit ${unitId} deleted successfully`);
} else {
const result = await response.json();
@@ -621,8 +701,11 @@
`;
resultDiv.classList.remove('hidden');
// Trigger roster refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
// Close modal after 2 seconds
setTimeout(() => closeImportModal(), 2000);
@@ -637,6 +720,105 @@
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>
<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 %}

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,6 +12,13 @@
</a>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white" id="pageTitle">Loading...</h1>
<div class="flex gap-3">
<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">
<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>
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>
@@ -20,6 +27,7 @@
</button>
</div>
</div>
</div>
<!-- Loading state -->
<div id="loadingState" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-12 text-center">
@@ -61,15 +69,98 @@
</div>
<!-- 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>
<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>
</div>
<!-- Edit Unit Form -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<!-- View Mode: Unit Information (Default) -->
<div id="viewMode" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Unit Information</h2>
<div class="space-y-6">
<!-- Basic Info Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Device Type</label>
<p id="viewDeviceType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Unit Type</label>
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project ID</label>
<p id="viewProjectId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div class="md:col-span-2">
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Coordinates</label>
<p id="viewCoordinates" class="mt-1 text-gray-900 dark:text-white font-medium font-mono text-sm">--</p>
</div>
</div>
<!-- Seismograph Info -->
<div id="viewSeismographFields" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Seismograph Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Calibrated</label>
<p id="viewLastCalibrated" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Next Calibration Due</label>
<p id="viewNextCalibrationDue" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
<p id="viewDeployedWithModemId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
</div>
</div>
<!-- Modem Info -->
<div id="viewModemFields" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Modem Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">IP Address</label>
<p id="viewIpAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Phone Number</label>
<p id="viewPhoneNumber" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Hardware Model</label>
<p id="viewHardwareModel" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
</div>
</div>
<!-- Notes -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
</div>
</div>
</div>
<!-- Edit Mode: Unit Information Form (Hidden by default) -->
<div id="editMode" class="hidden rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Edit Unit Information</h2>
<button onclick="cancelEdit()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="editForm" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Device Type -->
@@ -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">
</div>
<!-- Location -->
<!-- Address -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
<input type="text" name="location" id="location"
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<input type="text" name="address" id="address" placeholder="123 Main St, City, State"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<!-- Coordinates -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
<input type="text" name="coordinates" id="coordinates" placeholder="34.0522,-118.2437"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange font-mono">
</div>
</div>
<!-- Seismograph Fields -->
@@ -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>
</div>
<!-- Save Button -->
<!-- Save/Cancel Buttons -->
<div class="flex gap-3">
<button type="submit" class="flex-1 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
Save Changes
</button>
<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
</a>
</button>
</div>
</form>
</div>
@@ -185,69 +283,137 @@
<script>
const unitId = "{{ unit_id }}";
let currentUnit = null;
let currentSnapshot = null;
let unitMap = null;
let mapMarker = null;
// Load unit data on page load
async function loadUnitData() {
try {
const response = await fetch(`/api/roster/${unitId}`);
if (!response.ok) {
// Fetch unit roster data
const rosterResponse = await fetch(`/api/roster/${unitId}`);
if (!rosterResponse.ok) {
throw new Error('Unit not found');
}
currentUnit = await rosterResponse.json();
currentUnit = await response.json();
populateForm();
// Fetch snapshot data for status info
const snapshotResponse = await fetch('/api/status-snapshot');
if (snapshotResponse.ok) {
currentSnapshot = await snapshotResponse.json();
}
// Populate views
populateViewMode();
populateEditForm();
// Hide loading, show content
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('mainContent').classList.remove('hidden');
// Initialize map after content is visible
setTimeout(() => {
initUnitMap();
}, 100);
} catch (error) {
alert(`Error loading unit: ${error.message}`);
window.location.href = '/roster';
}
}
// Populate form with unit data
function populateForm() {
// Populate view mode (read-only display)
function populateViewMode() {
// Update page title
document.getElementById('pageTitle').textContent = `Unit ${currentUnit.id}`;
// Status info
// Get status info from snapshot
let unitStatus = null;
if (currentSnapshot && currentSnapshot.units) {
unitStatus = currentSnapshot.units[unitId];
}
// Status card
if (unitStatus) {
const statusColors = {
'OK': 'bg-green-500',
'Pending': 'bg-yellow-500',
'Missing': 'bg-red-500'
};
document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors.OK || 'bg-gray-400'}`;
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('retiredStatus').textContent = currentUnit.retired ? 'Yes' : 'No';
// Form fields
document.getElementById('deviceType').value = currentUnit.device_type;
document.getElementById('unitType').value = currentUnit.unit_type;
document.getElementById('projectId').value = currentUnit.project_id;
document.getElementById('location').value = currentUnit.location;
document.getElementById('deployed').checked = currentUnit.deployed;
document.getElementById('retired').checked = currentUnit.retired;
document.getElementById('note').value = currentUnit.note;
// Basic info
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
document.getElementById('viewProjectId').textContent = currentUnit.project_id || '--';
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
// Seismograph fields
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated;
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due;
document.getElementById('deployedWithModemId').value = currentUnit.deployed_with_modem_id;
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
document.getElementById('viewDeployedWithModemId').textContent = currentUnit.deployed_with_modem_id || '--';
// Modem fields
document.getElementById('ipAddress').value = currentUnit.ip_address;
document.getElementById('phoneNumber').value = currentUnit.phone_number;
document.getElementById('hardwareModel').value = currentUnit.hardware_model;
document.getElementById('viewIpAddress').textContent = currentUnit.ip_address || '--';
document.getElementById('viewPhoneNumber').textContent = currentUnit.phone_number || '--';
document.getElementById('viewHardwareModel').textContent = currentUnit.hardware_model || '--';
// Notes
document.getElementById('viewNote').textContent = currentUnit.note || '--';
// Show/hide fields based on device type
if (currentUnit.device_type === 'modem') {
document.getElementById('viewSeismographFields').classList.add('hidden');
document.getElementById('viewModemFields').classList.remove('hidden');
} else {
document.getElementById('viewSeismographFields').classList.remove('hidden');
document.getElementById('viewModemFields').classList.add('hidden');
}
}
// Populate edit form
function populateEditForm() {
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
document.getElementById('unitType').value = currentUnit.unit_type || '';
document.getElementById('projectId').value = currentUnit.project_id || '';
document.getElementById('address').value = currentUnit.address || '';
document.getElementById('coordinates').value = currentUnit.coordinates || '';
document.getElementById('deployed').checked = currentUnit.deployed;
document.getElementById('retired').checked = currentUnit.retired;
document.getElementById('note').value = currentUnit.note || '';
// Seismograph fields
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || '';
document.getElementById('deployedWithModemId').value = currentUnit.deployed_with_modem_id || '';
// Modem fields
document.getElementById('ipAddress').value = currentUnit.ip_address || '';
document.getElementById('phoneNumber').value = currentUnit.phone_number || '';
document.getElementById('hardwareModel').value = currentUnit.hardware_model || '';
// Show/hide fields based on device type
toggleDetailFields();
// Update map with unit location
updateUnitMap(currentUnit);
}
// 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
document.getElementById('editForm').addEventListener('submit', async function(e) {
e.preventDefault();
@@ -279,7 +462,9 @@ document.getElementById('editForm').addEventListener('submit', async function(e)
if (response.ok) {
alert('Unit updated successfully!');
loadUnitData(); // Reload data
// Reload data and return to view mode
await loadUnitData();
cancelEdit();
} else {
const result = await response.json();
alert(`Error: ${result.detail || 'Unknown error'}`);
@@ -312,40 +497,71 @@ async function deleteUnit() {
}
}
// Initialize unit location map
let unitMap = null;
// Initialize unit location map (only called once)
function initUnitMap() {
// Default center (will be updated when location is loaded)
unitMap = L.map('unit-map').setView([39.8283, -98.5795], 4);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 18
}).addTo(unitMap);
}
function updateUnitMap(unit) {
if (!unit.location || !unitMap) {
if (!currentUnit.coordinates) {
document.getElementById('mapCard').classList.add('hidden');
return;
}
const coords = parseLocation(currentUnit.coordinates);
if (!coords) {
document.getElementById('mapCard').classList.add('hidden');
return;
}
const coords = parseLocation(unit.location);
if (coords) {
const [lat, lon] = coords;
// Show the map card
document.getElementById('mapCard').classList.remove('hidden');
// Center map on unit location
unitMap.setView([lat, lon], 13);
// Only initialize map if it doesn't exist
if (!unitMap) {
// Initialize map
unitMap = L.map('unit-map').setView([lat, lon], 13);
// Add marker with unit info
const statusColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'orange' : 'red';
// 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);
L.circleMarker([lat, lon], {
// 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',
@@ -354,19 +570,11 @@ function updateUnitMap(unit) {
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>
<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();
// Update location text
document.getElementById('locationText').textContent = `Coordinates: ${lat.toFixed(6)}, ${lon.toFixed(6)}`;
} else {
// Show map card but indicate location not mappable
document.getElementById('mapCard').classList.remove('hidden');
document.getElementById('locationText').textContent = `Location: ${unit.location} (coordinates not available)`;
}
}
function parseLocation(location) {
@@ -377,17 +585,15 @@ function parseLocation(location) {
if (parts.length === 2) {
const lat = parseFloat(parts[0]);
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];
}
}
// TODO: Add geocoding support for address strings
return null;
}
// Load data when page loads
initUnitMap();
loadUnitData();
</script>
{% endblock %}