diff --git a/backend/main.py b/backend/main.py index 35e5048..3e0003a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -98,6 +98,29 @@ async def roster_table_partial(request: Request): }) +@app.get("/partials/unknown-emitters", response_class=HTMLResponse) +async def unknown_emitters_partial(request: Request): + """Partial template for unknown emitters (HTMX)""" + snapshot = emit_status_snapshot() + + unknown_list = [] + for unit_id, unit_data in snapshot.get("unknown", {}).items(): + unknown_list.append({ + "id": unit_id, + "status": unit_data["status"], + "age": unit_data["age"], + "fname": unit_data.get("fname", ""), + }) + + # Sort by ID + unknown_list.sort(key=lambda x: x["id"]) + + return templates.TemplateResponse("partials/unknown_emitters.html", { + "request": request, + "unknown_units": unknown_list + }) + + @app.get("/health") def health_check(): """Health check endpoint""" diff --git a/backend/models.py b/backend/models.py index 19a0e92..6f1f9d2 100644 --- a/backend/models.py +++ b/backend/models.py @@ -28,4 +28,16 @@ class RosterUnit(Base): note = Column(String, nullable=True) project_id = Column(String, nullable=True) location = Column(String, nullable=True) - last_updated = Column(DateTime, default=datetime.utcnow) \ No newline at end of file + last_updated = Column(DateTime, default=datetime.utcnow) + + +class IgnoredUnit(Base): + """ + Ignored units: units that report but should be filtered out from unknown emitters. + Used to suppress noise from old projects. + """ + __tablename__ = "ignored_units" + + id = Column(String, primary_key=True, index=True) + reason = Column(String, nullable=True) + ignored_at = Column(DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index 257f00f..1922942 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 +from backend.models import RosterUnit, IgnoredUnit router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) @@ -180,3 +180,54 @@ async def import_csv( }, "details": results } + + +@router.post("/ignore/{unit_id}") +def ignore_unit(unit_id: str, reason: str = Form(""), db: Session = Depends(get_db)): + """ + Add a unit to the ignore list to suppress it from unknown emitters. + """ + # Check if already ignored + if db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first(): + raise HTTPException(status_code=400, detail="Unit already ignored") + + ignored = IgnoredUnit( + id=unit_id, + reason=reason, + ignored_at=datetime.utcnow() + ) + db.add(ignored) + db.commit() + return {"message": "Unit ignored", "id": unit_id} + + +@router.delete("/ignore/{unit_id}") +def unignore_unit(unit_id: str, db: Session = Depends(get_db)): + """ + Remove a unit from the ignore list. + """ + ignored = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first() + if not ignored: + raise HTTPException(status_code=404, detail="Unit not in ignore list") + + db.delete(ignored) + db.commit() + return {"message": "Unit unignored", "id": unit_id} + + +@router.get("/ignored") +def list_ignored_units(db: Session = Depends(get_db)): + """ + Get list of all ignored units. + """ + ignored_units = db.query(IgnoredUnit).all() + return { + "ignored": [ + { + "id": unit.id, + "reason": unit.reason, + "ignored_at": unit.ignored_at.isoformat() + } + for unit in ignored_units + ] + } diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index cf01943..f71b2f5 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone from sqlalchemy.orm import Session from backend.database import get_db_session -from backend.models import Emitter, RosterUnit +from backend.models import Emitter, RosterUnit, IgnoredUnit def ensure_utc(dt): @@ -33,6 +33,7 @@ def emit_status_snapshot(): try: roster = {r.id: r for r in db.query(RosterUnit).all()} emitters = {e.id: e for e in db.query(Emitter).all()} + ignored = {i.id for i in db.query(IgnoredUnit).all()} units = {} @@ -101,17 +102,25 @@ def emit_status_snapshot(): if u["retired"] } + # Unknown units - emitters that aren't in the roster and aren't ignored + unknown_units = { + uid: u for uid, u in units.items() + if uid not in roster and uid not in ignored + } + return { "timestamp": datetime.utcnow().isoformat(), "units": units, "active": active_units, "benched": benched_units, "retired": retired_units, + "unknown": unknown_units, "summary": { "total": len(units), "active": len(active_units), "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"), diff --git a/templates/dashboard.html b/templates/dashboard.html index 26e7095..a52a101 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -162,11 +162,11 @@ function updateDashboard(event) { const data = JSON.parse(event.detail.xhr.response); // ===== Fleet summary numbers ===== - document.getElementById('total-units').textContent = data.total_units ?? 0; - document.getElementById('deployed-units').textContent = data.deployed_units ?? 0; - document.getElementById('status-ok').textContent = data.status_summary.OK ?? 0; - document.getElementById('status-pending').textContent = data.status_summary.Pending ?? 0; - document.getElementById('status-missing').textContent = data.status_summary.Missing ?? 0; + document.getElementById('total-units').textContent = data.summary?.total ?? 0; + document.getElementById('deployed-units').textContent = data.summary?.active ?? 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'); diff --git a/templates/partials/unknown_emitters.html b/templates/partials/unknown_emitters.html new file mode 100644 index 0000000..584b3d7 --- /dev/null +++ b/templates/partials/unknown_emitters.html @@ -0,0 +1,61 @@ +{% if unknown_units %} +
+
+ + + +
+

Unknown Emitters Detected

+

+ {{ unknown_units|length }} unit(s) are reporting but not in the roster. Add them to track them properly. +

+
+
+ +
+ {% for unit in unknown_units %} +
+
+
+ {{ unit.id }} +
+
+ + {{ unit.status }} + + + Last seen: {{ unit.age }} + + {% if unit.fname %} + + {{ unit.fname }} + + {% endif %} +
+
+
+ + +
+
+ {% endfor %} +
+
+{% endif %} diff --git a/templates/roster.html b/templates/roster.html index 78b8608..29bc0dd 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -26,6 +26,11 @@ + +
+ +
+
@@ -155,6 +160,42 @@ document.getElementById('addUnitForm').reset(); } + // Add unknown unit to roster + function addUnknownUnit(unitId) { + openAddUnitModal(); + // Pre-fill the unit ID + document.querySelector('#addUnitForm input[name="id"]').value = unitId; + // Set deployed to true by default + document.querySelector('#addUnitForm input[name="deployed"]').checked = true; + } + + // Ignore unknown unit + async function ignoreUnknownUnit(unitId) { + if (!confirm(`Ignore unit ${unitId}? It will no longer appear in the unknown emitters list.`)) { + return; + } + + try { + const formData = new FormData(); + formData.append('reason', 'Ignored from unknown emitters'); + + const response = await fetch(`/api/roster/ignore/${unitId}`, { + method: 'POST', + body: formData + }); + + if (response.ok) { + // Trigger refresh of unknown emitters + htmx.trigger(document.querySelector('[hx-get="/partials/unknown-emitters"]'), 'load'); + } else { + const result = await response.json(); + alert(`Error ignoring unit: ${result.detail || 'Unknown error'}`); + } + } catch (error) { + alert(`Error ignoring unit: ${error.message}`); + } + } + // Import Modal function openImportModal() { document.getElementById('importModal').classList.remove('hidden');