From 4cef580185c8f8bd9a604990eb065526d02d5f5a Mon Sep 17 00:00:00 2001 From: serversdwn Date: Wed, 3 Dec 2025 21:23:18 +0000 Subject: [PATCH] v0.2.1. many features added and cleaned up. --- backend/main.py | 115 ++++- backend/models.py | 4 +- backend/routers/roster_edit.py | 41 +- backend/routers/settings.py | 241 +++++++++++ backend/services/snapshot.py | 13 +- templates/base.html | 4 +- templates/dashboard.html | 23 +- templates/partials/ignored_table.html | 69 +++ templates/partials/retired_table.html | 77 ++++ templates/partials/roster_table.html | 155 ++++++- templates/roster.html | 260 ++++++++++-- templates/settings.html | 584 ++++++++++++++++++++++++++ templates/unit_detail.html | 410 +++++++++++++----- 13 files changed, 1815 insertions(+), 181 deletions(-) create mode 100644 backend/routers/settings.py create mode 100644 templates/partials/ignored_table.html create mode 100644 templates/partials/retired_table.html create mode 100644 templates/settings.html diff --git a/backend/main.py b/backend/main.py index a61f2b5..e9264e8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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)""" diff --git a/backend/models.py b/backend/models.py index 6474d80..d941264 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index a29ab89..82f2061 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -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) diff --git a/backend/routers/settings.py b/backend/routers/settings.py new file mode 100644 index 0000000..7063209 --- /dev/null +++ b/backend/routers/settings.py @@ -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)}") diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index fa10bdb..478d987 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -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: diff --git a/templates/base.html b/templates/base.html index eb72c35..e2d496c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -68,7 +68,7 @@ Seismo
Fleet Manager -

v0.1.1

+

v 0.2.1

@@ -94,7 +94,7 @@ Projects - + diff --git a/templates/dashboard.html b/templates/dashboard.html index 4e3308a..c11fd06 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -35,7 +35,12 @@ Deployed -- +
+ Benched + -- +
+

Deployed Status:

@@ -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; } } diff --git a/templates/partials/ignored_table.html b/templates/partials/ignored_table.html new file mode 100644 index 0000000..0b0a105 --- /dev/null +++ b/templates/partials/ignored_table.html @@ -0,0 +1,69 @@ +
+ + + + + + + + + + + {% if ignored_units %} + {% for unit in ignored_units %} + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
+ Unit ID + + Reason + + Ignored At + + Actions +
+
+ + + {{ unit.id }} + +
+
+ {{ unit.reason }} + + {{ unit.ignored_at }} + +
+ + +
+
+ No ignored units +
+ + +
+ Last updated: {{ timestamp }} +
+
diff --git a/templates/partials/retired_table.html b/templates/partials/retired_table.html new file mode 100644 index 0000000..cb6cf68 --- /dev/null +++ b/templates/partials/retired_table.html @@ -0,0 +1,77 @@ +
+ + + + + + + + + + + {% if units %} + {% for unit in units %} + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
+ Unit ID + + Type + + Note + + Actions +
+ + + {% if unit.device_type == 'modem' %} + + Modem + + {% else %} + + Seismograph + + {% endif %} + + {{ unit.note }} + +
+ + +
+
+ No retired units +
+ + +
+ Last updated: {{ timestamp }} +
+
diff --git a/templates/partials/roster_table.html b/templates/partials/roster_table.html index f893aaf..6a48d34 100644 --- a/templates/partials/roster_table.html +++ b/templates/partials/roster_table.html @@ -1,36 +1,60 @@
- +
- - - - - - - + {% for unit in units %} - +
- Status + +
+ Status + +
- Unit ID + +
+ Unit ID + +
- Type + +
+ Type + +
Details - Last Seen + +
+ Last Seen + +
- Age + +
+ Age + +
- Note + +
+ Note + +
Actions
{% if unit.status == 'OK' %} @@ -159,7 +183,108 @@
+ + diff --git a/templates/roster.html b/templates/roster.html index 1a22ff5..527a6d8 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -31,22 +31,56 @@ - -
- -
-
-
-
-
-
-
-
-
-
-
-
+ +
+ + +
+ + + + + + +
+ + +
+

Loading roster data...

+
+
@@ -143,6 +177,11 @@ class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded"> Deployed +
@@ -175,7 +214,7 @@
-
+
- - Address +
+
+ + +
@@ -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) { - closeEditUnitModal(); - // Trigger roster refresh - htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load'); - alert('Unit updated successfully!'); - } else { - alert('Error updating unit. Please check the form and try again.'); + document.getElementById('editUnitForm').addEventListener('submit', async function(event) { + event.preventDefault(); + + const unitId = this.dataset.unitId; + if (!unitId) { + alert('Error: Unit ID not found'); + return; + } + + const formData = new FormData(this); + + try { + const response = await fetch(`/api/roster/edit/${unitId}`, { + method: 'POST', + body: formData + }); + + if (response.ok) { + closeEditUnitModal(); + // Trigger roster refresh for current active tab + htmx.ajax('GET', currentRosterEndpoint, { + target: '#roster-content', + swap: 'innerHTML' + }); + alert('Unit updated successfully!'); + } else { + const result = await response.json(); + alert(`Error: ${result.detail || 'Unknown error'}`); + } + } catch (error) { + alert(`Error updating unit: ${error.message}`); } }); @@ -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}`); + } + } + + {% endblock %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..9eb0245 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,584 @@ +{% extends "base.html" %} + +{% block title %}Settings - Seismo Fleet Manager{% endblock %} + +{% block content %} +
+

Roster Manager

+

Manage your fleet roster data - import, export, and reset

+
+ + +
+
+
+

Export Roster

+

+ Download all roster data as CSV for backup or editing externally +

+
+ +
+
+ + +
+

Import Roster

+ + +
+ + +

+ CSV must include column: unit_id (required) +

+
+ +
+ +
+ + +
+
+ + + + + +
+ + +
+
+
+

Roster Units

+

+ Manage all units with inline editing and quick actions +

+
+ +
+ + +
+
+

Loading roster units...

+
+ + + + + + +
+ + +
+
+ + + +
+

Danger Zone

+

+ Irreversible operations - use with extreme caution +

+
+
+ +
+ +
+
+

Clear All Data

+

+ Delete ALL roster units, emitters, and ignored units +

+
+ +
+ + +
+
+

Clear Roster Table

+

+ Delete all roster units only (keeps emitters and ignored units) +

+
+ +
+ + +
+
+

Clear Emitters Table

+

+ Delete all auto-discovered emitters (will repopulate automatically) +

+
+ +
+ + +
+
+

Clear Ignored Units

+

+ Remove all units from the ignore list +

+
+ +
+
+
+ + +{% endblock %} diff --git a/templates/unit_detail.html b/templates/unit_detail.html index ed312bb..016a500 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -12,12 +12,20 @@

Loading...

- +
+ + +
@@ -61,15 +69,98 @@ -
+ - -
+ +

Unit Information

+ +
+ +
+
+ +

--

+
+
+ +

--

+
+
+ +

--

+
+
+ +

--

+
+
+ +

--

+
+
+ + +
+

Seismograph Information

+
+
+ +

--

+
+
+ +

--

+
+
+ +

--

+
+
+
+ + + + + +
+ +

--

+
+
+
+ + + @@ -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">
- +
- +
@@ -185,69 +283,137 @@ {% endblock %}