Compare commits
1 Commits
0e2086d6bb
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 56bd3041cf |
@@ -9,6 +9,7 @@ import logging
|
|||||||
import httpx
|
import httpx
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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)
|
days = int(hours_ago / 24)
|
||||||
time_ago = f"{days}d ago"
|
time_ago = f"{days}d ago"
|
||||||
|
|
||||||
|
loc = get_active_location(db, emitter.id) if roster_unit else None
|
||||||
call_in = {
|
call_in = {
|
||||||
"unit_id": emitter.id,
|
"unit_id": emitter.id,
|
||||||
"last_seen": emitter.last_seen.isoformat(),
|
"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",
|
"device_type": roster_unit.device_type if roster_unit else "seismograph",
|
||||||
"deployed": roster_unit.deployed if roster_unit else False,
|
"deployed": roster_unit.deployed if roster_unit else False,
|
||||||
"note": roster_unit.note if roster_unit and roster_unit.note else "",
|
"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)
|
call_ins.append(call_in)
|
||||||
|
|
||||||
|
|||||||
@@ -750,15 +750,17 @@ async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
# Last seen from emitter
|
# Last seen from emitter
|
||||||
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
|
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 {
|
return {
|
||||||
"id": u.id,
|
"id": u.id,
|
||||||
"unit_type": u.unit_type,
|
"unit_type": u.unit_type,
|
||||||
"deployed": u.deployed,
|
"deployed": u.deployed,
|
||||||
"out_for_calibration": u.out_for_calibration or False,
|
"out_for_calibration": u.out_for_calibration or False,
|
||||||
"note": u.note or "",
|
"note": u.note or "",
|
||||||
"project_id": u.project_id or "",
|
"project_id": (loc or {}).get("project_id") or u.project_id or "",
|
||||||
"address": u.address or u.location or "",
|
"address": (loc or {}).get("address") or "",
|
||||||
"coordinates": u.coordinates or "",
|
"coordinates": (loc or {}).get("coordinates") or "",
|
||||||
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
||||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
"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),
|
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import logging
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -85,8 +86,7 @@ async def get_modem_units(
|
|||||||
(RosterUnit.id.ilike(search_term)) |
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
(RosterUnit.ip_address.ilike(search_term)) |
|
(RosterUnit.ip_address.ilike(search_term)) |
|
||||||
(RosterUnit.hardware_model.ilike(search_term)) |
|
(RosterUnit.hardware_model.ilike(search_term)) |
|
||||||
(RosterUnit.phone_number.ilike(search_term)) |
|
(RosterUnit.phone_number.ilike(search_term))
|
||||||
(RosterUnit.location.ilike(search_term))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
modems = query.order_by(
|
modems = query.order_by(
|
||||||
@@ -128,6 +128,8 @@ async def get_modem_units(
|
|||||||
if filter_status and status != filter_status:
|
if filter_status and status != filter_status:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Inherit location from the paired device's active assignment.
|
||||||
|
loc = get_active_location(db, modem.id) if paired else None
|
||||||
modem_list.append({
|
modem_list.append({
|
||||||
"id": modem.id,
|
"id": modem.id,
|
||||||
"ip_address": modem.ip_address,
|
"ip_address": modem.ip_address,
|
||||||
@@ -135,8 +137,8 @@ async def get_modem_units(
|
|||||||
"hardware_model": modem.hardware_model,
|
"hardware_model": modem.hardware_model,
|
||||||
"deployed": modem.deployed,
|
"deployed": modem.deployed,
|
||||||
"retired": modem.retired,
|
"retired": modem.retired,
|
||||||
"location": modem.location,
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
||||||
"project_id": modem.project_id,
|
"project_id": (loc or {}).get("project_id") or modem.project_id,
|
||||||
"paired_device": paired,
|
"paired_device": paired,
|
||||||
"status": status
|
"status": status
|
||||||
})
|
})
|
||||||
@@ -165,14 +167,15 @@ async def get_paired_device(modem_id: str, db: Session = Depends(get_db)):
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if device:
|
if device:
|
||||||
|
loc = get_active_location(db, device.id)
|
||||||
return {
|
return {
|
||||||
"paired": True,
|
"paired": True,
|
||||||
"device": {
|
"device": {
|
||||||
"id": device.id,
|
"id": device.id,
|
||||||
"device_type": device.device_type,
|
"device_type": device.device_type,
|
||||||
"deployed": device.deployed,
|
"deployed": device.deployed,
|
||||||
"project_id": device.project_id,
|
"project_id": (loc or {}).get("project_id") or device.project_id,
|
||||||
"location": device.location or device.address
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,8 +317,6 @@ async def get_pairable_devices(
|
|||||||
query = query.filter(
|
query = query.filter(
|
||||||
(RosterUnit.id.ilike(search_term)) |
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
(RosterUnit.project_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))
|
(RosterUnit.note.ilike(search_term))
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -338,12 +339,13 @@ async def get_pairable_devices(
|
|||||||
if hide_paired and is_paired_to_other:
|
if hide_paired and is_paired_to_other:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
loc = get_active_location(db, device.id)
|
||||||
device_list.append({
|
device_list.append({
|
||||||
"id": device.id,
|
"id": device.id,
|
||||||
"device_type": device.device_type,
|
"device_type": device.device_type,
|
||||||
"deployed": device.deployed,
|
"deployed": device.deployed,
|
||||||
"project_id": device.project_id,
|
"project_id": (loc or {}).get("project_id") or device.project_id,
|
||||||
"location": device.location or device.address,
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
||||||
"note": device.note,
|
"note": device.note,
|
||||||
"paired_modem_id": device.deployed_with_modem_id,
|
"paired_modem_id": device.deployed_with_modem_id,
|
||||||
"is_paired_to_this": is_paired_to_this,
|
"is_paired_to_this": is_paired_to_this,
|
||||||
|
|||||||
@@ -1483,11 +1483,13 @@ async def get_available_units(
|
|||||||
).distinct().all()
|
).distinct().all()
|
||||||
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
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 = [
|
available_units = [
|
||||||
{
|
{
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"device_type": unit.device_type,
|
"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,
|
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
||||||
"deployed": bool(unit.deployed),
|
"deployed": bool(unit.deployed),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from backend.database import get_db
|
|||||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
||||||
import uuid
|
import uuid
|
||||||
from backend.services.slmm_sync import sync_slm_to_slmm
|
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"])
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -182,9 +183,6 @@ async def add_roster_unit(
|
|||||||
out_for_calibration: str = Form(None),
|
out_for_calibration: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
|
||||||
address: str = Form(None),
|
|
||||||
coordinates: str = Form(None),
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated: str = Form(None),
|
last_calibrated: str = Form(None),
|
||||||
next_calibration_due: 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,
|
out_for_calibration=out_for_calibration_bool,
|
||||||
note=note,
|
note=note,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location=location,
|
|
||||||
address=address,
|
|
||||||
coordinates=coordinates,
|
|
||||||
last_updated=datetime.utcnow(),
|
last_updated=datetime.utcnow(),
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated=last_cal_date,
|
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,
|
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:
|
if deployed_with_modem_id:
|
||||||
modem = db.query(RosterUnit).filter(
|
modem = db.query(RosterUnit).filter(
|
||||||
RosterUnit.id == deployed_with_modem_id,
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
RosterUnit.device_type == "modem"
|
RosterUnit.device_type == "modem"
|
||||||
).first()
|
).first()
|
||||||
if modem:
|
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:
|
if not unit.project_id and modem.project_id:
|
||||||
unit.project_id = modem.project_id
|
unit.project_id = modem.project_id
|
||||||
if not unit.note and modem.note:
|
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:
|
if not unit:
|
||||||
raise HTTPException(status_code=404, detail="Unit not found")
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
active_loc = get_active_location(db, unit_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"device_type": unit.device_type or "seismograph",
|
"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 "",
|
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
|
||||||
"note": unit.note or "",
|
"note": unit.note or "",
|
||||||
"project_id": unit.project_id or "",
|
"project_id": unit.project_id or "",
|
||||||
"location": unit.location or "",
|
"active_location": active_loc,
|
||||||
"address": unit.address or "",
|
# Convenience fields so the unit-detail page can read the same shape
|
||||||
"coordinates": unit.coordinates or "",
|
# 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 "",
|
"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 "",
|
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
|
||||||
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
"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),
|
allocated_to_project_id: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
|
||||||
address: str = Form(None),
|
|
||||||
coordinates: str = Form(None),
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated: str = Form(None),
|
last_calibrated: str = Form(None),
|
||||||
next_calibration_due: str = Form(None),
|
next_calibration_due: str = Form(None),
|
||||||
@@ -565,8 +557,6 @@ async def edit_roster_unit(
|
|||||||
cascade_deployed: str = Form(None),
|
cascade_deployed: str = Form(None),
|
||||||
cascade_retired: str = Form(None),
|
cascade_retired: str = Form(None),
|
||||||
cascade_project: str = Form(None),
|
cascade_project: str = Form(None),
|
||||||
cascade_location: str = Form(None),
|
|
||||||
cascade_coordinates: str = Form(None),
|
|
||||||
cascade_note: str = Form(None),
|
cascade_note: str = Form(None),
|
||||||
db: Session = Depends(get_db)
|
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.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
|
||||||
unit.note = note
|
unit.note = note
|
||||||
unit.project_id = project_id
|
unit.project_id = project_id
|
||||||
unit.location = location
|
|
||||||
unit.address = address
|
|
||||||
unit.coordinates = coordinates
|
|
||||||
unit.last_updated = datetime.utcnow()
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
@@ -630,20 +617,15 @@ async def edit_roster_unit(
|
|||||||
unit.next_calibration_due = next_cal_date
|
unit.next_calibration_due = next_cal_date
|
||||||
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
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:
|
if deployed_with_modem_id:
|
||||||
modem = db.query(RosterUnit).filter(
|
modem = db.query(RosterUnit).filter(
|
||||||
RosterUnit.id == deployed_with_modem_id,
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
RosterUnit.device_type == "modem"
|
RosterUnit.device_type == "modem"
|
||||||
).first()
|
).first()
|
||||||
if modem:
|
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:
|
if not unit.project_id and modem.project_id:
|
||||||
unit.project_id = modem.project_id
|
unit.project_id = modem.project_id
|
||||||
if not unit.note and modem.note:
|
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",
|
record_history(db, paired_unit.id, "project_change", "project_id",
|
||||||
old_paired_project or "", project_id or "", f"cascade from {unit_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
|
# Cascade note
|
||||||
if cascade_note in ['true', 'True', '1', 'yes']:
|
if cascade_note in ['true', 'True', '1', 'yes']:
|
||||||
old_paired_note = paired_unit.note
|
old_paired_note = paired_unit.note
|
||||||
@@ -1011,9 +973,8 @@ async def import_csv(
|
|||||||
- retired: Boolean
|
- retired: Boolean
|
||||||
- note: Notes about the unit
|
- note: Notes about the unit
|
||||||
- project_id: Project identifier
|
- project_id: Project identifier
|
||||||
- location: Location description
|
(Location / address / coordinates are not roster fields anymore — they
|
||||||
- address: Street address
|
live on the MonitoringLocation a unit is assigned to.)
|
||||||
- coordinates: GPS coordinates (lat;lon or lat,lon)
|
|
||||||
|
|
||||||
Seismograph-specific:
|
Seismograph-specific:
|
||||||
- last_calibrated: Date (YYYY-MM-DD)
|
- 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.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.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.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()
|
existing_unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
@@ -1194,9 +1152,6 @@ async def import_csv(
|
|||||||
retired=_parse_bool(row.get('retired', '')),
|
retired=_parse_bool(row.get('retired', '')),
|
||||||
note=_get_csv_value(row, 'note', ''),
|
note=_get_csv_value(row, 'note', ''),
|
||||||
project_id=_get_csv_value(row, 'project_id'),
|
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(),
|
last_updated=datetime.utcnow(),
|
||||||
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
|
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
|
||||||
last_calibrated=last_cal,
|
last_calibrated=last_cal,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||||
from backend.services.database_backup import DatabaseBackupService
|
from backend.services.database_backup import DatabaseBackupService
|
||||||
|
from backend.services.unit_location import bulk_active_locations
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
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"""
|
"""Export all roster units to CSV"""
|
||||||
units = db.query(RosterUnit).all()
|
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()
|
output = io.StringIO()
|
||||||
fieldnames = [
|
fieldnames = [
|
||||||
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
|
'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',
|
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
|
||||||
'ip_address', 'phone_number', 'hardware_model'
|
'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',
|
'retired': 'true' if unit.retired else 'false',
|
||||||
'note': unit.note or '',
|
'note': unit.note or '',
|
||||||
'project_id': unit.project_id 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 '',
|
'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 '',
|
'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 '',
|
'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)):
|
def get_all_roster_units(db: Session = Depends(get_db)):
|
||||||
"""Get all roster units for management table"""
|
"""Get all roster units for management table"""
|
||||||
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
|
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
|
||||||
|
active_locs = bulk_active_locations(db, units)
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
@@ -90,10 +92,10 @@ def get_all_roster_units(db: Session = Depends(get_db)):
|
|||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
"note": unit.note or "",
|
"note": unit.note or "",
|
||||||
"project_id": unit.project_id or "",
|
"project_id": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "",
|
||||||
"location": unit.location or "",
|
"address": (active_locs.get(unit.id) or {}).get("address") or "",
|
||||||
"address": unit.address or "",
|
"coordinates": (active_locs.get(unit.id) or {}).get("coordinates") or "",
|
||||||
"coordinates": unit.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,
|
"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,
|
"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 "",
|
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import os
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
||||||
|
|
||||||
|
loc = get_active_location(db, unit_id)
|
||||||
return {
|
return {
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"device_type": "slm",
|
"device_type": "slm",
|
||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"model": unit.slm_model or "NL-43",
|
"model": unit.slm_model or "NL-43",
|
||||||
"location": unit.address or unit.location,
|
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
||||||
"coordinates": unit.coordinates,
|
"coordinates": (loc or {}).get("coordinates") or "",
|
||||||
"note": unit.note,
|
"note": unit.note,
|
||||||
"status": status_data,
|
"status": status_data,
|
||||||
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||||
|
|||||||
+12
-16
@@ -5,6 +5,7 @@ from typing import Dict, Any, Optional
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
from backend.services.unit_location import get_active_location
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["units"])
|
router = APIRouter(prefix="/api", tags=["units"])
|
||||||
@@ -13,7 +14,8 @@ router = APIRouter(prefix="/api", tags=["units"])
|
|||||||
@router.get("/unit/{unit_id}")
|
@router.get("/unit/{unit_id}")
|
||||||
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
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()
|
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")
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
unit_data = snapshot["units"][unit_id]
|
unit_data = snapshot["units"][unit_id]
|
||||||
|
active_loc = get_active_location(db, 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"})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit_id,
|
"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", ""),
|
"last_file": unit_data.get("fname", ""),
|
||||||
"deployed": unit_data["deployed"],
|
"deployed": unit_data["deployed"],
|
||||||
"note": unit_data.get("note", ""),
|
"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)):
|
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Get unit data directly from the roster (for settings/configuration).
|
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()
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
|
||||||
if not unit:
|
if not unit:
|
||||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
|
active_loc = get_active_location(db, unit_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"unit_type": unit.unit_type,
|
"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,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
"note": unit.note,
|
"note": unit.note,
|
||||||
"location": unit.location,
|
"active_location": active_loc,
|
||||||
"address": unit.address,
|
"address": (active_loc or {}).get("address") or "",
|
||||||
"coordinates": unit.coordinates,
|
"coordinates": (active_loc or {}).get("coordinates") or "",
|
||||||
"slm_host": unit.slm_host,
|
"slm_host": unit.slm_host,
|
||||||
"slm_tcp_port": unit.slm_tcp_port,
|
"slm_tcp_port": unit.slm_tcp_port,
|
||||||
"slm_ftp_port": unit.slm_ftp_port,
|
"slm_ftp_port": unit.slm_ftp_port,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from backend.database import get_db_session
|
from backend.database import get_db_session
|
||||||
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
||||||
|
from backend.services.unit_location import bulk_active_locations
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -137,6 +138,10 @@ def emit_status_snapshot():
|
|||||||
emitters = {e.id: e for e in db.query(Emitter).all()}
|
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||||
ignored = {i.id for i in db.query(IgnoredUnit).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
|
# SFM event-forwards are now the primary "last seen" signal for
|
||||||
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
|
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
|
||||||
# or hasn't seen a serial, we fall back to Emitter.last_seen.
|
# 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,
|
"ip_address": r.ip_address,
|
||||||
"phone_number": r.phone_number,
|
"phone_number": r.phone_number,
|
||||||
"hardware_model": r.hardware_model,
|
"hardware_model": r.hardware_model,
|
||||||
# Location for mapping
|
# Location for mapping — sourced from active UnitAssignment
|
||||||
"location": r.location or "",
|
# → MonitoringLocation. Empty for benched / unassigned.
|
||||||
"address": r.address or "",
|
"address": (active_locs.get(unit_id) or {}).get("address") or "",
|
||||||
"coordinates": r.coordinates 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 ---
|
# --- Add unexpected emitter-only units ---
|
||||||
@@ -267,10 +275,12 @@ def emit_status_snapshot():
|
|||||||
"ip_address": None,
|
"ip_address": None,
|
||||||
"phone_number": None,
|
"phone_number": None,
|
||||||
"hardware_model": None,
|
"hardware_model": None,
|
||||||
# Location fields
|
# Location fields — unknown units have no assignment
|
||||||
"location": "",
|
|
||||||
"address": "",
|
"address": "",
|
||||||
"coordinates": "",
|
"coordinates": "",
|
||||||
|
"location_name": "",
|
||||||
|
"project_id": "",
|
||||||
|
"location_id": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Derive modem status from paired devices ---
|
# --- Derive modem status from paired devices ---
|
||||||
@@ -301,6 +311,11 @@ def emit_status_snapshot():
|
|||||||
unit_data["last"] = paired_unit.get("last")
|
unit_data["last"] = paired_unit.get("last")
|
||||||
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
|
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
|
||||||
unit_data["derived_from"] = paired_unit_id
|
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
|
# Separate buckets for UI
|
||||||
active_units = {
|
active_units = {
|
||||||
|
|||||||
@@ -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
@@ -150,46 +150,55 @@ setInterval(_refreshPendingDeployBanner, 30000);
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3 card-content" id="fleet-summary-content">
|
<div class="space-y-4 card-content" id="fleet-summary-content">
|
||||||
<div class="flex justify-between items-center">
|
<!-- Seismographs -->
|
||||||
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
<div>
|
||||||
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
<div class="flex justify-between items-center mb-1.5">
|
||||||
</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="flex items-center">
|
<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">
|
<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>
|
<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>
|
</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>
|
</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>
|
||||||
<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">
|
<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">
|
<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>
|
<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>
|
</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="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>
|
||||||
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
<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 justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-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.forEach(marker => fleetMap.removeLayer(marker));
|
||||||
fleetMarkers = [];
|
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 || {})
|
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) {
|
if (deployedUnits.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -672,10 +686,12 @@ function updateFleetMapFiltered(allUnits) {
|
|||||||
// Popup with device type
|
// Popup with device type
|
||||||
const deviceLabel = getDeviceTypeLabel(deviceType);
|
const deviceLabel = getDeviceTypeLabel(deviceType);
|
||||||
|
|
||||||
|
const locName = unit.location_name || '';
|
||||||
marker.bindPopup(`
|
marker.bindPopup(`
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<h3 class="font-bold text-lg">${id}</h3>
|
<h3 class="font-bold text-lg">${id}</h3>
|
||||||
<p class="text-sm text-gray-600">${deviceLabel}</p>
|
<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>
|
<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>` : ''}
|
${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>
|
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
|
||||||
@@ -783,32 +799,51 @@ function updateDashboard(event) {
|
|||||||
timeZoneName: 'short'
|
timeZoneName: 'short'
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Fleet summary numbers (always unfiltered) =====
|
// ===== Fleet Summary: per-device-type counts (always unfiltered) =====
|
||||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
// Deployed = unit has an active UnitAssignment (location_id set by
|
||||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
// the snapshot helper). Benched = no active assignment.
|
||||||
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
// Retired, out-for-calibration, and roster-unknown units (emitters
|
||||||
document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
|
// not in the roster) are excluded from totals.
|
||||||
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
const counts = {
|
||||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
seismograph: { total: 0, deployed: 0, benched: 0 },
|
||||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 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) =====
|
Object.entries(data.units || {}).forEach(([uid, unit]) => {
|
||||||
let seismoCount = 0;
|
if (unit.retired || unit.out_for_calibration) return;
|
||||||
let slmCount = 0;
|
if (unknownIds.has(uid)) return;
|
||||||
let modemCount = 0;
|
const dt = unit.device_type || 'seismograph';
|
||||||
Object.values(data.units || {}).forEach(unit => {
|
const bucket = counts[dt];
|
||||||
if (unit.retired) return; // Don't count retired units
|
if (!bucket) return; // skip modems and anything else
|
||||||
const deviceType = unit.device_type || 'seismograph';
|
|
||||||
if (deviceType === 'seismograph') {
|
bucket.total++;
|
||||||
seismoCount++;
|
if (unit.location_id) {
|
||||||
} else if (deviceType === 'sound_level_meter') {
|
bucket.deployed++;
|
||||||
slmCount++;
|
} else {
|
||||||
} else if (deviceType === 'modem') {
|
bucket.benched++;
|
||||||
modemCount++;
|
}
|
||||||
|
|
||||||
|
// 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 =====
|
// ===== Apply filters and render map + alerts =====
|
||||||
renderFilteredDashboard(data);
|
renderFilteredDashboard(data);
|
||||||
|
|||||||
+47
-35
@@ -129,6 +129,15 @@
|
|||||||
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
<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>
|
<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 %}
|
{% include "partials/project_picker.html" with context %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Address -->
|
<!-- Address / coordinates are managed on the project's
|
||||||
<div>
|
MonitoringLocation, not the unit itself. Edit them on
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
the project page. -->
|
||||||
<input type="text" name="address" id="address" placeholder="123 Main St, City, State"
|
<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">
|
||||||
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">
|
Address & coordinates are set on the deployment location.
|
||||||
</div>
|
Open the project to edit them.
|
||||||
|
|
||||||
<!-- 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">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -848,16 +851,6 @@
|
|||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
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>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
||||||
</label>
|
</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">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
|
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
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');
|
if (projectLink) projectLink.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
// Deployment Location — comes from the active UnitAssignment →
|
||||||
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
// 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
|
// Seismograph fields
|
||||||
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
||||||
@@ -1327,8 +1340,6 @@ function populateEditForm() {
|
|||||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
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('deployed').checked = currentUnit.deployed;
|
||||||
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
|
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
|
||||||
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
||||||
@@ -1609,8 +1620,13 @@ function initUnitMap() {
|
|||||||
// Update marker (can be called multiple times)
|
// Update marker (can be called multiple times)
|
||||||
updateMapMarker(lat, lon);
|
updateMapMarker(lat, lon);
|
||||||
|
|
||||||
// Update location text
|
// Update location text — prefer the assignment's location name, fall
|
||||||
|
// back to address, then coordinates.
|
||||||
const locationParts = [];
|
const locationParts = [];
|
||||||
|
const loc = currentUnit.active_location;
|
||||||
|
if (loc && loc.name) {
|
||||||
|
locationParts.push(loc.name);
|
||||||
|
}
|
||||||
if (currentUnit.address) {
|
if (currentUnit.address) {
|
||||||
locationParts.push(currentUnit.address);
|
locationParts.push(currentUnit.address);
|
||||||
}
|
}
|
||||||
@@ -1724,13 +1740,12 @@ async function uploadPhoto(file) {
|
|||||||
|
|
||||||
const result = await response.json();
|
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!';
|
let message = 'Photo uploaded successfully!';
|
||||||
if (result.metadata && result.metadata.coordinates) {
|
if (result.metadata && result.metadata.coordinates) {
|
||||||
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
||||||
if (result.coordinates_updated) {
|
|
||||||
message += ' (Unit coordinates updated automatically)';
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
message += ' No GPS data found in photo.';
|
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.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
||||||
statusDiv.textContent = message;
|
statusDiv.textContent = message;
|
||||||
|
|
||||||
// Reload photos and unit data
|
// Reload photos
|
||||||
await loadPhotos();
|
await loadPhotos();
|
||||||
if (result.coordinates_updated) {
|
|
||||||
await loadUnitData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide status after 5 seconds
|
// Hide status after 5 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user