feat(dashboard): clarify the fleet status card and swap map locations to project monitoring location coords.

feat: Location no longer assigned directly to unit, locations and coords are assigned to location only, unit only is deployed or benched.
This commit is contained in:
2026-06-01 22:01:38 +00:00
parent 623ef648b7
commit 56bd3041cf
12 changed files with 345 additions and 195 deletions
+21 -6
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from backend.database import get_db_session
from backend.models import Emitter, RosterUnit, IgnoredUnit
from backend.services.unit_location import bulk_active_locations
log = logging.getLogger(__name__)
@@ -137,6 +138,10 @@ def emit_status_snapshot():
emitters = {e.id: e for e in db.query(Emitter).all()}
ignored = {i.id for i in db.query(IgnoredUnit).all()}
# Active-assignment location lookup for all roster units (direct only;
# modems inherit from their paired device below in the derive loop).
active_locs = bulk_active_locations(db, list(roster.values()))
# SFM event-forwards are now the primary "last seen" signal for
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
# or hasn't seen a serial, we fall back to Emitter.last_seen.
@@ -225,10 +230,13 @@ def emit_status_snapshot():
"ip_address": r.ip_address,
"phone_number": r.phone_number,
"hardware_model": r.hardware_model,
# Location for mapping
"location": r.location or "",
"address": r.address or "",
"coordinates": r.coordinates or "",
# Location for mapping — sourced from active UnitAssignment
# → MonitoringLocation. Empty for benched / unassigned.
"address": (active_locs.get(unit_id) or {}).get("address") or "",
"coordinates": (active_locs.get(unit_id) or {}).get("coordinates") or "",
"location_name": (active_locs.get(unit_id) or {}).get("name") or "",
"project_id": (active_locs.get(unit_id) or {}).get("project_id") or "",
"location_id": (active_locs.get(unit_id) or {}).get("location_id") or "",
}
# --- Add unexpected emitter-only units ---
@@ -267,10 +275,12 @@ def emit_status_snapshot():
"ip_address": None,
"phone_number": None,
"hardware_model": None,
# Location fields
"location": "",
# Location fields — unknown units have no assignment
"address": "",
"coordinates": "",
"location_name": "",
"project_id": "",
"location_id": "",
}
# --- Derive modem status from paired devices ---
@@ -301,6 +311,11 @@ def emit_status_snapshot():
unit_data["last"] = paired_unit.get("last")
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
unit_data["derived_from"] = paired_unit_id
# Inherit deployment location too — modems don't carry
# their own UnitAssignment.
for k in ("address", "coordinates", "location_name", "project_id", "location_id"):
if not unit_data.get(k):
unit_data[k] = paired_unit.get(k, "")
# Separate buckets for UI
active_units = {
+125
View File
@@ -0,0 +1,125 @@
"""
Active-assignment location resolution for roster units.
`RosterUnit.location`, `.address`, `.coordinates` are legacy per-unit fields.
The current source of truth for "where is this unit deployed right now" is the
active `UnitAssignment` (assigned_until IS NULL) pointing at a
`MonitoringLocation`, which carries the canonical address/coordinates/name.
Modems don't get their own `UnitAssignment` — they're paired with a
seismograph or SLM via `deployed_with_unit_id`. A deployed modem inherits the
location of its paired device's active assignment.
Returned dict shape (or None if no active assignment resolvable):
{
"location_id": "uuid",
"project_id": "uuid",
"name": "NRL-001",
"address": "123 Main St" | None,
"coordinates": "34.0522,-118.2437" | None,
"via_paired_unit_id": "BE1234" | None, # set only for modems
}
"""
from typing import Optional
from sqlalchemy.orm import Session
from backend.models import MonitoringLocation, RosterUnit, UnitAssignment
def _serialize(loc: MonitoringLocation, via_paired_unit_id: Optional[str] = None) -> dict:
return {
"location_id": loc.id,
"project_id": loc.project_id,
"name": loc.name,
"address": loc.address or None,
"coordinates": loc.coordinates or None,
"via_paired_unit_id": via_paired_unit_id,
}
def _active_location_for_unit_id(db: Session, unit_id: str) -> Optional[MonitoringLocation]:
"""Return the MonitoringLocation tied to this unit's active assignment, if any."""
row = (
db.query(MonitoringLocation)
.join(UnitAssignment, UnitAssignment.location_id == MonitoringLocation.id)
.filter(
UnitAssignment.unit_id == unit_id,
UnitAssignment.assigned_until == None, # noqa: E711
)
.order_by(UnitAssignment.assigned_at.desc())
.first()
)
return row
def get_active_location(db: Session, unit_id: str) -> Optional[dict]:
"""
Resolve the active deployment location for a unit.
Seismographs / SLMs: their own active UnitAssignment.
Modems: follow `deployed_with_unit_id` to the paired device's active
assignment (modems don't carry their own assignment).
"""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if unit is None:
return None
if (unit.device_type or "seismograph") == "modem":
paired_id = unit.deployed_with_unit_id
if not paired_id:
return None
loc = _active_location_for_unit_id(db, paired_id)
return _serialize(loc, via_paired_unit_id=paired_id) if loc else None
loc = _active_location_for_unit_id(db, unit_id)
return _serialize(loc) if loc else None
def bulk_active_locations(db: Session, units: list[RosterUnit]) -> dict[str, dict]:
"""
Resolve active locations for many units in two queries. Use this from
snapshot-style loops to avoid N+1 lookups.
Returns {unit_id: <serialized location dict>} — only populated for units
that resolve to an active assignment. Modems are resolved by walking
`deployed_with_unit_id` to the paired device's entry in the same map.
"""
if not units:
return {}
direct_unit_ids = [
u.id for u in units
if (u.device_type or "seismograph") != "modem"
]
direct: dict[str, MonitoringLocation] = {}
if direct_unit_ids:
rows = (
db.query(UnitAssignment.unit_id, MonitoringLocation)
.join(MonitoringLocation, MonitoringLocation.id == UnitAssignment.location_id)
.filter(
UnitAssignment.unit_id.in_(direct_unit_ids),
UnitAssignment.assigned_until == None, # noqa: E711
)
.order_by(UnitAssignment.assigned_at.desc())
.all()
)
# First row wins per unit_id (most recent assigned_at).
for unit_id, loc in rows:
direct.setdefault(unit_id, loc)
out: dict[str, dict] = {
uid: _serialize(loc) for uid, loc in direct.items()
}
# Modems inherit from paired device.
for u in units:
if (u.device_type or "seismograph") != "modem":
continue
paired_id = u.deployed_with_unit_id
if paired_id and paired_id in direct:
out[u.id] = _serialize(direct[paired_id], via_paired_unit_id=paired_id)
return out