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
+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,