1 Commits

Author SHA1 Message Date
serversdown 56bd3041cf 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.
2026-06-01 22:01:38 +00:00
12 changed files with 345 additions and 195 deletions
+3 -1
View File
@@ -9,6 +9,7 @@ import logging
import httpx
from backend.database import get_db
from backend.models import UnitHistory, Emitter, RosterUnit
from backend.services.unit_location import get_active_location
log = logging.getLogger(__name__)
@@ -140,6 +141,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
days = int(hours_ago / 24)
time_ago = f"{days}d ago"
loc = get_active_location(db, emitter.id) if roster_unit else None
call_in = {
"unit_id": emitter.id,
"last_seen": emitter.last_seen.isoformat(),
@@ -148,7 +150,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
"device_type": roster_unit.device_type if roster_unit else "seismograph",
"deployed": roster_unit.deployed if roster_unit else False,
"note": roster_unit.note if roster_unit and roster_unit.note else "",
"location": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "")
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
}
call_ins.append(call_in)
+5 -3
View File
@@ -750,15 +750,17 @@ async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
# Last seen from emitter
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
from backend.services.unit_location import get_active_location
loc = get_active_location(db, u.id)
return {
"id": u.id,
"unit_type": u.unit_type,
"deployed": u.deployed,
"out_for_calibration": u.out_for_calibration or False,
"note": u.note or "",
"project_id": u.project_id or "",
"address": u.address or u.location or "",
"coordinates": u.coordinates or "",
"project_id": (loc or {}).get("project_id") or u.project_id or "",
"address": (loc or {}).get("address") or "",
"coordinates": (loc or {}).get("coordinates") or "",
"deployed_with_modem_id": u.deployed_with_modem_id or "",
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
+12 -10
View File
@@ -14,6 +14,7 @@ import logging
from backend.database import get_db
from backend.models import RosterUnit
from backend.services.unit_location import get_active_location
from backend.templates_config import templates
logger = logging.getLogger(__name__)
@@ -85,8 +86,7 @@ async def get_modem_units(
(RosterUnit.id.ilike(search_term)) |
(RosterUnit.ip_address.ilike(search_term)) |
(RosterUnit.hardware_model.ilike(search_term)) |
(RosterUnit.phone_number.ilike(search_term)) |
(RosterUnit.location.ilike(search_term))
(RosterUnit.phone_number.ilike(search_term))
)
modems = query.order_by(
@@ -128,6 +128,8 @@ async def get_modem_units(
if filter_status and status != filter_status:
continue
# Inherit location from the paired device's active assignment.
loc = get_active_location(db, modem.id) if paired else None
modem_list.append({
"id": modem.id,
"ip_address": modem.ip_address,
@@ -135,8 +137,8 @@ async def get_modem_units(
"hardware_model": modem.hardware_model,
"deployed": modem.deployed,
"retired": modem.retired,
"location": modem.location,
"project_id": modem.project_id,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
"project_id": (loc or {}).get("project_id") or modem.project_id,
"paired_device": paired,
"status": status
})
@@ -165,14 +167,15 @@ async def get_paired_device(modem_id: str, db: Session = Depends(get_db)):
).first()
if device:
loc = get_active_location(db, device.id)
return {
"paired": True,
"device": {
"id": device.id,
"device_type": device.device_type,
"deployed": device.deployed,
"project_id": device.project_id,
"location": device.location or device.address
"project_id": (loc or {}).get("project_id") or device.project_id,
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
}
}
@@ -314,8 +317,6 @@ async def get_pairable_devices(
query = query.filter(
(RosterUnit.id.ilike(search_term)) |
(RosterUnit.project_id.ilike(search_term)) |
(RosterUnit.location.ilike(search_term)) |
(RosterUnit.address.ilike(search_term)) |
(RosterUnit.note.ilike(search_term))
)
@@ -338,12 +339,13 @@ async def get_pairable_devices(
if hide_paired and is_paired_to_other:
continue
loc = get_active_location(db, device.id)
device_list.append({
"id": device.id,
"device_type": device.device_type,
"deployed": device.deployed,
"project_id": device.project_id,
"location": device.location or device.address,
"project_id": (loc or {}).get("project_id") or device.project_id,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
"note": device.note,
"paired_modem_id": device.deployed_with_modem_id,
"is_paired_to_this": is_paired_to_this,
+3 -1
View File
@@ -1483,11 +1483,13 @@ async def get_available_units(
).distinct().all()
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
# These units have no active assignment by definition, so there's no
# current location to show — leave the field empty.
available_units = [
{
"id": unit.id,
"device_type": unit.device_type,
"location": unit.address or unit.location,
"location": "",
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
"deployed": bool(unit.deployed),
}
+16 -61
View File
@@ -12,6 +12,7 @@ from backend.database import get_db
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
import uuid
from backend.services.slmm_sync import sync_slm_to_slmm
from backend.services.unit_location import get_active_location
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
logger = logging.getLogger(__name__)
@@ -182,9 +183,6 @@ async def add_roster_unit(
out_for_calibration: str = Form(None),
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),
@@ -249,9 +247,6 @@ async def add_roster_unit(
out_for_calibration=out_for_calibration_bool,
note=note,
project_id=project_id,
location=location,
address=address,
coordinates=coordinates,
last_updated=datetime.utcnow(),
# Seismograph-specific fields
last_calibrated=last_cal_date,
@@ -273,19 +268,15 @@ async def add_roster_unit(
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
)
# Auto-fill data from modem if pairing and fields are empty
# Auto-fill data from modem if pairing and fields are empty.
# Location/address/coordinates now come from MonitoringLocation via the
# active UnitAssignment, so there's nothing to copy from the modem row.
if deployed_with_modem_id:
modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id,
RosterUnit.device_type == "modem"
).first()
if modem:
if not unit.location and modem.location:
unit.location = modem.location
if not unit.address and modem.address:
unit.address = modem.address
if not unit.coordinates and modem.coordinates:
unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id:
unit.project_id = modem.project_id
if not unit.note and modem.note:
@@ -493,6 +484,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
active_loc = get_active_location(db, unit_id)
return {
"id": unit.id,
"device_type": unit.device_type or "seismograph",
@@ -504,9 +497,11 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
"note": unit.note or "",
"project_id": unit.project_id or "",
"location": unit.location or "",
"address": unit.address or "",
"coordinates": unit.coordinates or "",
"active_location": active_loc,
# Convenience fields so the unit-detail page can read the same shape
# whether or not there's an active assignment.
"address": (active_loc or {}).get("address") or "",
"coordinates": (active_loc or {}).get("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 "",
@@ -538,9 +533,6 @@ async def edit_roster_unit(
allocated_to_project_id: str = Form(None),
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),
@@ -565,8 +557,6 @@ async def edit_roster_unit(
cascade_deployed: str = Form(None),
cascade_retired: str = Form(None),
cascade_project: str = Form(None),
cascade_location: str = Form(None),
cascade_coordinates: str = Form(None),
cascade_note: str = Form(None),
db: Session = Depends(get_db)
):
@@ -620,9 +610,6 @@ async def edit_roster_unit(
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
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
@@ -630,20 +617,15 @@ async def edit_roster_unit(
unit.next_calibration_due = next_cal_date
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
# Auto-fill data from modem if pairing and fields are empty
# Auto-fill data from modem if pairing and fields are empty.
# Location/address/coordinates live on MonitoringLocation now, nothing
# to copy across roster rows.
if deployed_with_modem_id:
modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id,
RosterUnit.device_type == "modem"
).first()
if modem:
# Only fill if the device field is empty
if not unit.location and modem.location:
unit.location = modem.location
if not unit.address and modem.address:
unit.address = modem.address
if not unit.coordinates and modem.coordinates:
unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id:
unit.project_id = modem.project_id
if not unit.note and modem.note:
@@ -769,26 +751,6 @@ async def edit_roster_unit(
record_history(db, paired_unit.id, "project_change", "project_id",
old_paired_project or "", project_id or "", f"cascade from {unit_id}")
# Cascade address/location
if cascade_location in ['true', 'True', '1', 'yes']:
old_paired_address = paired_unit.address
old_paired_location = paired_unit.location
paired_unit.address = address
paired_unit.location = location
paired_unit.last_updated = datetime.utcnow()
if old_paired_address != address:
record_history(db, paired_unit.id, "address_change", "address",
old_paired_address or "", address or "", f"cascade from {unit_id}")
# Cascade coordinates
if cascade_coordinates in ['true', 'True', '1', 'yes']:
old_paired_coords = paired_unit.coordinates
paired_unit.coordinates = coordinates
paired_unit.last_updated = datetime.utcnow()
if old_paired_coords != coordinates:
record_history(db, paired_unit.id, "coordinates_change", "coordinates",
old_paired_coords or "", coordinates or "", f"cascade from {unit_id}")
# Cascade note
if cascade_note in ['true', 'True', '1', 'yes']:
old_paired_note = paired_unit.note
@@ -1011,9 +973,8 @@ async def import_csv(
- retired: Boolean
- note: Notes about the unit
- project_id: Project identifier
- location: Location description
- address: Street address
- coordinates: GPS coordinates (lat;lon or lat,lon)
(Location / address / coordinates are not roster fields anymore — they
live on the MonitoringLocation a unit is assigned to.)
Seismograph-specific:
- last_calibrated: Date (YYYY-MM-DD)
@@ -1126,9 +1087,6 @@ async def import_csv(
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
existing_unit.address = _get_csv_value(row, 'address', existing_unit.address)
existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates)
existing_unit.last_updated = datetime.utcnow()
# Seismograph-specific fields
@@ -1194,9 +1152,6 @@ async def import_csv(
retired=_parse_bool(row.get('retired', '')),
note=_get_csv_value(row, 'note', ''),
project_id=_get_csv_value(row, 'project_id'),
location=_get_csv_value(row, 'location'),
address=_get_csv_value(row, 'address'),
coordinates=_get_csv_value(row, 'coordinates'),
last_updated=datetime.utcnow(),
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
last_calibrated=last_cal,
+11 -9
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from backend.database import get_db
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
from backend.services.database_backup import DatabaseBackupService
from backend.services.unit_location import bulk_active_locations
router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -21,11 +22,14 @@ def export_roster_csv(db: Session = Depends(get_db)):
"""Export all roster units to CSV"""
units = db.query(RosterUnit).all()
# Create CSV in memory
# Create CSV in memory. Location lives on MonitoringLocation now, so
# we don't export legacy address/coordinates/location columns here —
# round-trip CSV editing would otherwise look like it edits unit
# location, when it can't.
output = io.StringIO()
fieldnames = [
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
'note', 'project_id', 'location', 'address', 'coordinates',
'note', 'project_id',
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
'ip_address', 'phone_number', 'hardware_model'
]
@@ -42,9 +46,6 @@ def export_roster_csv(db: Session = Depends(get_db)):
'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 '',
@@ -82,6 +83,7 @@ def get_table_stats(db: Session = Depends(get_db)):
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()
active_locs = bulk_active_locations(db, units)
return [{
"id": unit.id,
@@ -90,10 +92,10 @@ def get_all_roster_units(db: Session = Depends(get_db)):
"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 "",
"project_id": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "",
"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 "",
"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 "",
+4 -2
View File
@@ -14,6 +14,7 @@ import os
from backend.database import get_db
from backend.models import RosterUnit
from backend.services.unit_location import get_active_location
from backend.templates_config import templates
logger = logging.getLogger(__name__)
@@ -58,13 +59,14 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
except Exception as e:
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
loc = get_active_location(db, unit_id)
return {
"unit_id": unit_id,
"device_type": "slm",
"deployed": unit.deployed,
"model": unit.slm_model or "NL-43",
"location": unit.address or unit.location,
"coordinates": unit.coordinates,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
"coordinates": (loc or {}).get("coordinates") or "",
"note": unit.note,
"status": status_data,
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
+12 -16
View File
@@ -5,6 +5,7 @@ from typing import Dict, Any, Optional
from backend.database import get_db
from backend.services.snapshot import emit_status_snapshot
from backend.services.unit_location import get_active_location
from backend.models import RosterUnit
router = APIRouter(prefix="/api", tags=["units"])
@@ -13,7 +14,8 @@ router = APIRouter(prefix="/api", tags=["units"])
@router.get("/unit/{unit_id}")
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
"""
Returns detailed data for a single unit.
Returns detailed data for a single unit, including its active deployment
location (or None if benched / unassigned).
"""
snapshot = emit_status_snapshot()
@@ -21,17 +23,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
unit_data = snapshot["units"][unit_id]
# Mock coordinates for now (will be replaced with real data)
mock_coords = {
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
}
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
active_loc = get_active_location(db, unit_id)
return {
"id": unit_id,
@@ -41,7 +33,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
"last_file": unit_data.get("fname", ""),
"deployed": unit_data["deployed"],
"note": unit_data.get("note", ""),
"coordinates": coords
"active_location": active_loc,
}
@@ -49,12 +41,16 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
"""
Get unit data directly from the roster (for settings/configuration).
Address/coordinates come from the active MonitoringLocation, not the
roster row.
"""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
active_loc = get_active_location(db, unit_id)
return {
"id": unit.id,
"unit_type": unit.unit_type,
@@ -62,9 +58,9 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
"deployed": unit.deployed,
"retired": unit.retired,
"note": unit.note,
"location": unit.location,
"address": unit.address,
"coordinates": unit.coordinates,
"active_location": active_loc,
"address": (active_loc or {}).get("address") or "",
"coordinates": (active_loc or {}).get("coordinates") or "",
"slm_host": unit.slm_host,
"slm_tcp_port": unit.slm_tcp_port,
"slm_ftp_port": unit.slm_ftp_port,
+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
+86 -51
View File
@@ -150,46 +150,55 @@ setInterval(_refreshPendingDeployBanner, 30000);
</svg>
</div>
</div>
<div class="space-y-3 card-content" id="fleet-summary-content">
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
<span id="deployed-units" class="text-3xl md: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-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
</div>
<div class="flex justify-between items-center">
<span class="text-orange-600 dark:text-orange-400">Allocated</span>
<span id="allocated-units" class="text-3xl md:text-2xl font-bold text-orange-500 dark:text-orange-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">By Device Type:</p>
<div class="flex justify-between items-center mb-1">
<div class="space-y-4 card-content" id="fleet-summary-content">
<!-- Seismographs -->
<div>
<div class="flex justify-between items-center mb-1.5">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
<a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
<a href="/seismographs" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
</div>
<span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
<span id="seismo-count" class="text-lg font-bold text-blue-600 dark:text-blue-400">--</span>
</div>
<div class="flex justify-between items-center mb-2">
<div class="pl-6 flex flex-col gap-0.5 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
<span id="seismo-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Benched</span>
<span id="seismo-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
</div>
</div>
<!-- Sound Level Meters -->
<div>
<div class="flex justify-between items-center mb-1.5">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
</svg>
<a href="/sound-level-meters" class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
<a href="/sound-level-meters" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
</div>
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
<span id="slm-count" class="text-lg font-bold text-purple-600 dark:text-purple-400">--</span>
</div>
<div class="pl-6 flex flex-col gap-0.5 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
<span id="slm-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Benched</span>
<span id="slm-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
</div>
</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>
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Call-in Status:</p>
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
<div class="flex items-center">
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
@@ -628,9 +637,14 @@ function updateFleetMapFiltered(allUnits) {
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
fleetMarkers = [];
// Get deployed units with coordinates that pass the filter
// Get deployed units with coordinates that pass the filter.
// Modems are not plotted — they inherit the paired device's location,
// which would just stack a duplicate marker on the same pin.
const deployedUnits = Object.entries(allUnits || {})
.filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u));
.filter(([_, u]) => u.deployed
&& u.coordinates
&& (u.device_type || 'seismograph') !== 'modem'
&& unitPassesFilter(u));
if (deployedUnits.length === 0) {
return;
@@ -672,10 +686,12 @@ function updateFleetMapFiltered(allUnits) {
// Popup with device type
const deviceLabel = getDeviceTypeLabel(deviceType);
const locName = unit.location_name || '';
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg">${id}</h3>
<p class="text-sm text-gray-600">${deviceLabel}</p>
${locName ? `<p class="text-sm text-gray-700">📍 ${locName}</p>` : ''}
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
@@ -783,32 +799,51 @@ function updateDashboard(event) {
timeZoneName: 'short'
});
// ===== Fleet summary numbers (always unfiltered) =====
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('allocated-units').textContent = data.summary?.allocated ?? 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;
// ===== Fleet Summary: per-device-type counts (always unfiltered) =====
// Deployed = unit has an active UnitAssignment (location_id set by
// the snapshot helper). Benched = no active assignment.
// Retired, out-for-calibration, and roster-unknown units (emitters
// not in the roster) are excluded from totals.
const counts = {
seismograph: { total: 0, deployed: 0, benched: 0 },
sound_level_meter: { total: 0, deployed: 0, benched: 0 },
};
let monitoredOk = 0, monitoredPending = 0, monitoredMissing = 0;
const unknownIds = new Set(Object.keys(data.unknown || {}));
// ===== Device type counts (always unfiltered) =====
let seismoCount = 0;
let slmCount = 0;
let modemCount = 0;
Object.values(data.units || {}).forEach(unit => {
if (unit.retired) return; // Don't count retired units
const deviceType = unit.device_type || 'seismograph';
if (deviceType === 'seismograph') {
seismoCount++;
} else if (deviceType === 'sound_level_meter') {
slmCount++;
} else if (deviceType === 'modem') {
modemCount++;
Object.entries(data.units || {}).forEach(([uid, unit]) => {
if (unit.retired || unit.out_for_calibration) return;
if (unknownIds.has(uid)) return;
const dt = unit.device_type || 'seismograph';
const bucket = counts[dt];
if (!bucket) return; // skip modems and anything else
bucket.total++;
if (unit.location_id) {
bucket.deployed++;
} else {
bucket.benched++;
}
// Status tally only for seismographs + SLMs that are actually
// deployed (assigned). Mirrors the per-device buckets so the
// sum matches.
if (unit.location_id) {
if (unit.status === 'OK') monitoredOk++;
else if (unit.status === 'Pending') monitoredPending++;
else if (unit.status === 'Missing') monitoredMissing++;
}
});
document.getElementById('seismo-count').textContent = seismoCount;
document.getElementById('slm-count').textContent = slmCount;
document.getElementById('seismo-count').textContent = counts.seismograph.total;
document.getElementById('seismo-deployed').textContent = counts.seismograph.deployed;
document.getElementById('seismo-benched').textContent = counts.seismograph.benched;
document.getElementById('slm-count').textContent = counts.sound_level_meter.total;
document.getElementById('slm-deployed').textContent = counts.sound_level_meter.deployed;
document.getElementById('slm-benched').textContent = counts.sound_level_meter.benched;
document.getElementById('status-ok').textContent = monitoredOk;
document.getElementById('status-pending').textContent = monitoredPending;
document.getElementById('status-missing').textContent = monitoredMissing;
// ===== Apply filters and render map + alerts =====
renderFilteredDashboard(data);
+47 -35
View File
@@ -129,6 +129,15 @@
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployment Location</label>
<p id="viewLocationContainer" class="mt-1">
<a id="viewLocationLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
<span id="viewLocationText">--</span>
</a>
<span id="viewLocationNoLink" class="text-gray-500 dark:text-gray-400 italic">Not deployed</span>
</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>
@@ -639,18 +648,12 @@
{% include "partials/project_picker.html" with context %}
</div>
<!-- Address -->
<div>
<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">
<!-- Address / coordinates are managed on the project's
MonitoringLocation, not the unit itself. Edit them on
the project page. -->
<div class="md:col-span-2 rounded-lg bg-gray-50 dark:bg-slate-700/50 border border-gray-200 dark:border-gray-700 p-3 text-sm text-gray-600 dark:text-gray-400">
Address &amp; coordinates are set on the deployment location.
Open the project to edit them.
</div>
</div>
@@ -848,16 +851,6 @@
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_location" id="detailCascadeLocation" 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">Address</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_coordinates" id="detailCascadeCoordinates" 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">Coordinates</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
@@ -1168,8 +1161,28 @@ function populateViewMode() {
if (projectLink) projectLink.classList.add('hidden');
}
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
// Deployment Location — comes from the active UnitAssignment →
// MonitoringLocation. Show project link if present, otherwise
// "Not deployed" placeholder.
const locLink = document.getElementById('viewLocationLink');
const locText = document.getElementById('viewLocationText');
const locNoLink = document.getElementById('viewLocationNoLink');
const activeLoc = currentUnit.active_location;
if (activeLoc && activeLoc.location_id) {
if (locText) locText.textContent = activeLoc.name || activeLoc.address || 'Active location';
if (locLink) {
locLink.href = `/projects/${activeLoc.project_id}`;
locLink.classList.remove('hidden');
}
if (locNoLink) locNoLink.classList.add('hidden');
} else {
if (locLink) locLink.classList.add('hidden');
if (locNoLink) locNoLink.classList.remove('hidden');
}
// Address / coordinates also come from the active assignment.
document.getElementById('viewAddress').textContent = (activeLoc && activeLoc.address) || '--';
document.getElementById('viewCoordinates').textContent = (activeLoc && activeLoc.coordinates) || '--';
// Seismograph fields
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
@@ -1327,8 +1340,6 @@ function populateEditForm() {
if (projectPickerClear) projectPickerClear.classList.add('hidden');
}
document.getElementById('address').value = currentUnit.address || '';
document.getElementById('coordinates').value = currentUnit.coordinates || '';
document.getElementById('deployed').checked = currentUnit.deployed;
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
@@ -1609,8 +1620,13 @@ function initUnitMap() {
// Update marker (can be called multiple times)
updateMapMarker(lat, lon);
// Update location text
// Update location text — prefer the assignment's location name, fall
// back to address, then coordinates.
const locationParts = [];
const loc = currentUnit.active_location;
if (loc && loc.name) {
locationParts.push(loc.name);
}
if (currentUnit.address) {
locationParts.push(currentUnit.address);
}
@@ -1724,13 +1740,12 @@ async function uploadPhoto(file) {
const result = await response.json();
// Show success message with metadata info
// Show success message with metadata info. Location is on the
// assignment's MonitoringLocation now, so we just surface what GPS
// came in — the backend no longer mutates the unit row.
let message = 'Photo uploaded successfully!';
if (result.metadata && result.metadata.coordinates) {
message += ` GPS location detected: ${result.metadata.coordinates}`;
if (result.coordinates_updated) {
message += ' (Unit coordinates updated automatically)';
}
} else {
message += ' No GPS data found in photo.';
}
@@ -1738,11 +1753,8 @@ async function uploadPhoto(file) {
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
statusDiv.textContent = message;
// Reload photos and unit data
// Reload photos
await loadPhotos();
if (result.coordinates_updated) {
await loadUnitData();
}
// Hide status after 5 seconds
setTimeout(() => {