4 Commits

Author SHA1 Message Date
serversdown 0e2086d6bb Merge pull request 'v0.13.2 - s4 event pipeline complete' (#56) from dev into main
Reviewed-on: #56
2026-06-01 17:41:18 -04:00
serversdown d0685baed5 Merge pull request 'v0.12.1 — Unit Swap wizard, editable timeline, roster/tz fixes' (#54) from dev into main
Reviewed-on: #54
docs+chore: v0.12.1 — Unit Swap wizard, editable timeline, roster/tz fixes

CHANGELOG entry for the five commits that landed after the v0.12.0 tag:
two features (Unit Swap wizard at /tools/unit-swap, editable deployment
timeline on /unit/{id}) and two correctness fixes (RosterUnit.deployed
now flips on swap/unassign/promote; deployment timeline now respects
user timezone for both display and edits).  No schema migrations.

README bumped to v0.12.1 with new bullets for the post-v0.12.0 features
and several already-shipped items that were missing from the list (SFM
Event DB Manager, Deployment-History calendar + Gantt tabs, reusable
location-map partial).  backend/main.py VERSION constant bumped too.
2026-05-20 11:44:47 -04:00
serversdown 275a168046 Merge pull request 'merge v0.12.0' (#51) from dev into main
Reviewed-on: #51
2026-05-17 19:44:56 -04:00
serversdown f4fd1c943d Merge pull request 'v0.11.0' (#50) from release/0.11.0 into main
## [0.11.0] - 2026-05-15

Operator-facing polish release.  All work builds on the v0.10.0 SFM integration foundation — this release is about making the day-to-day workflows (managing locations, cleaning up bad attributions, browsing deployments) faster and less error-prone.

### Added
- **Soft-remove monitoring locations** (`POST /api/projects/{p}/locations/{l}/remove` + `/restore`): mark a location as no longer actively monitored without destroying historical events.  Cascade-closes active unit assignments and cancels pending scheduled actions at the location.  Restored locations rejoin the active list (assignments are NOT auto-reopened — operator creates new ones if resuming).  Project page splits locations into Active and Removed sections; removed cards are greyed out, badged with the removal date + reason, and offer a Restore button.
- **Per-unit deployment Gantt chart** above the existing Deployment Timeline list on every seismograph unit detail page.  Plain-SVG rendering, color per location, today marker (orange dashed line), reduced-opacity bars for closed assignments, blue outlines on metadata-backfilled assignments, dashed blue underlines marking mergeable groups.  Click a bar to scroll the matching list row into view with a flash highlight.
- **Merge consecutive same-location assignments** (`POST /api/projects/{p}/assignments/merge`): operators often end up with several rows representing one continuous deployment (after remove/restore, or metadata-backfill adjacent to a manual record).  Now auto-detected and surfaceable in the timeline header — one click combines them into a single record.  Preserves the earliest record's notes + ingest source, writes an `assignment_merged` audit entry, deletes the others.
- **Delete assignment for mis-clicks** (`DELETE /api/projects/{p}/assignments/{a}`): hard-deletes a bogus assignment row that was never a real deployment.  Trash icon in each row of the location's Deployment History panel.  Refuses the delete if any `MonitoringSession` exists in the assignment's window — those should go through Unassign instead, which preserves audit history.  Writes an `assignment_deleted` UnitHistory row.
- **Drag-to-reorder location cards**: each active card has a six-dot drag handle on the left.  Drag/drop reorders the DOM and persists via `POST /api/projects/{p}/locations/reorder`.  Implementation uses native HTML5 drag-and-drop (no library).  New locations land at the end (`sort_order = max + 1`); removed locations stay sorted by removal date.
- **Three-dot kebab menu on location cards**: replaces the four inline pill buttons (Unassign / Edit / Remove / Delete) with a single ⋮ menu.  Click ⋮ to open; click outside or Escape to close; only one menu open at a time.
- **Event count on vibration location cards**: vibration cards now show "{N} events" sourced from SFM via concurrent fan-out, instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline).  Sound locations still show session counts.
- **Project overview location map**: right column of every project's overview replaces the lightly-used Upcoming Actions panel with a Leaflet map.  One pin per active monitoring location (parsed from the `coordinates` field).  Click pin → scrolls + flashes the matching card.  Tooltip on hover.  Locations without coordinates surface as an inline hint below the map.  If the project has pending scheduled actions, a small "{N} upcoming actions →" link appears in the card header that switches to the Schedules tab.

### Changed
- **Backfill location fuzzy matcher is now stricter**: `rapidfuzz.WRatio` was over-confident on location names because their shared boilerplate vocabulary ("Area", "Loc", numbers) inflated scores.  Example false positive that prompted the change: `"Area 2 - Brookville Dam - Loc 2 East"` vs `"Area 1 - Loc 1 - 87 Jenks"` scored 86% via WRatio.  Now uses `token_set_ratio` as the base scorer plus a 0.30 penalty when the two strings have disjoint multi-digit numeric tokens.  Catches the "same project, different address number" case (`"68 Jenks"` vs `"87 Jenks"`) that pure token-set scoring still rated above 0.90.  Project matching keeps WRatio (where its leniency is desirable for typos like `1-80` vs `I-80`).

### Fixed
- **Three separate JSON.stringify quote-collision bugs**: any inline `onclick="...({...} | tojson)"` or `onclick="...${JSON.stringify(x)}..."` where `x` contained any character that JSON quotes (essentially every real-world string) broke the HTML attribute and silently un-bound the click handler.  Surfaced in three places this release; all fixed by switching to `data-*` attributes plus a trampoline function reading from `this.dataset`:
    - **Location Remove button** on the project page
    - **Metadata-backfill typeahead dropdown** (existing project + location pickers)
    - **Project-merge typeahead dropdown** (in the per-project header)
- **Project-merge modal too short to show typeahead options without scrolling**: modal body's `flex-1 overflow-y-auto` collapsed tight; added `min-height: 480px` to the modal container + `min-h-[320px]` to the body so the dropdown always has room.
- **Project location map covered modals**: Leaflet's internal panes carry z-indexes 200–800 by default and the map container didn't establish a stacking context, so those z-indexes leaked into the root and outranked modals' `z-50`.  Fixed by adding `isolation: isolate` to the map container.
- **`delete_assignment` crashed with `AttributeError`**: the safety check queried `MonitoringSession.start_time` but the actual column is `started_at`.  Every DELETE call to `/assignments/{id}` failed with 500 before doing anything.

### Migration Notes
Run on each database before deploying.  Both migrations are idempotent and non-destructive.

```bash
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.py
```

Or sweep all migrations at once (safe — already-applied ones no-op):

```bash
for f in backend/migrate_*.py; do
  docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
done
```

New columns added this release:
- `monitoring_locations.removed_at` (DATETIME, nullable) — NULL means active
- `monitoring_locations.removal_reason` (TEXT, nullable)
- `monitoring_locations.sort_order` (INTEGER, default 0) — seeded to alphabetical-index per project on first migration

**Deploy order matters**: migrations must run BEFORE the new code is up, otherwise the running app will throw 500s on the unrecognized columns.  Idempotent migrations make this recoverable but it's better avoided — the v0.11.0 deploy on prod hit this exact window after the v0.10.0 release.

---
2026-05-15 19:16:42 -04:00
12 changed files with 195 additions and 345 deletions
+1 -3
View File
@@ -9,7 +9,6 @@ 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__)
@@ -141,7 +140,6 @@ 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(),
@@ -150,7 +148,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": (loc or {}).get("address") or (loc or {}).get("name") or "" "location": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "")
} }
call_ins.append(call_in) call_ins.append(call_in)
+3 -5
View File
@@ -750,17 +750,15 @@ 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": (loc or {}).get("project_id") or u.project_id or "", "project_id": u.project_id or "",
"address": (loc or {}).get("address") or "", "address": u.address or u.location or "",
"coordinates": (loc or {}).get("coordinates") or "", "coordinates": u.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),
+10 -12
View File
@@ -14,7 +14,6 @@ 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__)
@@ -86,7 +85,8 @@ 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,8 +128,6 @@ 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,
@@ -137,8 +135,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": (loc or {}).get("address") or (loc or {}).get("name") or "", "location": modem.location,
"project_id": (loc or {}).get("project_id") or modem.project_id, "project_id": modem.project_id,
"paired_device": paired, "paired_device": paired,
"status": status "status": status
}) })
@@ -167,15 +165,14 @@ 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": (loc or {}).get("project_id") or device.project_id, "project_id": device.project_id,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "" "location": device.location or device.address
} }
} }
@@ -317,6 +314,8 @@ 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))
) )
@@ -339,13 +338,12 @@ 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": (loc or {}).get("project_id") or device.project_id, "project_id": device.project_id,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "", "location": device.location or device.address,
"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,
+1 -3
View File
@@ -1483,13 +1483,11 @@ 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": "", "location": unit.address or unit.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),
} }
+61 -16
View File
@@ -12,7 +12,6 @@ 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__)
@@ -183,6 +182,9 @@ 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),
@@ -247,6 +249,9 @@ 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,
@@ -268,15 +273,19 @@ 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:
@@ -484,8 +493,6 @@ 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",
@@ -497,11 +504,9 @@ 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 "",
"active_location": active_loc, "location": unit.location or "",
# Convenience fields so the unit-detail page can read the same shape "address": unit.address or "",
# whether or not there's an active assignment. "coordinates": unit.coordinates or "",
"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 "",
@@ -533,6 +538,9 @@ 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),
@@ -557,6 +565,8 @@ 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)
): ):
@@ -610,6 +620,9 @@ 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
@@ -617,15 +630,20 @@ 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:
@@ -751,6 +769,26 @@ 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
@@ -973,8 +1011,9 @@ 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 / address / coordinates are not roster fields anymore — they - location: Location description
live on the MonitoringLocation a unit is assigned to.) - address: Street address
- coordinates: GPS coordinates (lat;lon or lat,lon)
Seismograph-specific: Seismograph-specific:
- last_calibrated: Date (YYYY-MM-DD) - last_calibrated: Date (YYYY-MM-DD)
@@ -1087,6 +1126,9 @@ 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
@@ -1152,6 +1194,9 @@ 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,
+9 -11
View File
@@ -12,7 +12,6 @@ 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"])
@@ -22,14 +21,11 @@ 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. Location lives on MonitoringLocation now, so # Create CSV in memory
# 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', 'note', 'project_id', 'location', 'address', 'coordinates',
'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'
] ]
@@ -46,6 +42,9 @@ 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 '',
@@ -83,7 +82,6 @@ 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,
@@ -92,10 +90,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": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "", "project_id": unit.project_id or "",
"address": (active_locs.get(unit.id) or {}).get("address") or "", "location": unit.location or "",
"coordinates": (active_locs.get(unit.id) or {}).get("coordinates") or "", "address": unit.address or "",
"location_name": (active_locs.get(unit.id) or {}).get("name") or "", "coordinates": unit.coordinates 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 "",
+2 -4
View File
@@ -14,7 +14,6 @@ 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__)
@@ -59,14 +58,13 @@ 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": (loc or {}).get("address") or (loc or {}).get("name") or "", "location": unit.address or unit.location,
"coordinates": (loc or {}).get("coordinates") or "", "coordinates": unit.coordinates,
"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,
+16 -12
View File
@@ -5,7 +5,6 @@ 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"])
@@ -14,8 +13,7 @@ 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, including its active deployment Returns detailed data for a single unit.
location (or None if benched / unassigned).
""" """
snapshot = emit_status_snapshot() snapshot = emit_status_snapshot()
@@ -23,7 +21,17 @@ 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,
@@ -33,7 +41,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", ""),
"active_location": active_loc, "coordinates": coords
} }
@@ -41,16 +49,12 @@ 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,
@@ -58,9 +62,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,
"active_location": active_loc, "location": unit.location,
"address": (active_loc or {}).get("address") or "", "address": unit.address,
"coordinates": (active_loc or {}).get("coordinates") or "", "coordinates": unit.coordinates,
"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,
+6 -21
View File
@@ -10,7 +10,6 @@ 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__)
@@ -138,10 +137,6 @@ 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.
@@ -230,13 +225,10 @@ 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 — sourced from active UnitAssignment # Location for mapping
# → MonitoringLocation. Empty for benched / unassigned. "location": r.location or "",
"address": (active_locs.get(unit_id) or {}).get("address") or "", "address": r.address or "",
"coordinates": (active_locs.get(unit_id) or {}).get("coordinates") or "", "coordinates": r.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 ---
@@ -275,12 +267,10 @@ def emit_status_snapshot():
"ip_address": None, "ip_address": None,
"phone_number": None, "phone_number": None,
"hardware_model": None, "hardware_model": None,
# Location fields — unknown units have no assignment # Location fields
"location": "",
"address": "", "address": "",
"coordinates": "", "coordinates": "",
"location_name": "",
"project_id": "",
"location_id": "",
} }
# --- Derive modem status from paired devices --- # --- Derive modem status from paired devices ---
@@ -311,11 +301,6 @@ 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 = {
-125
View File
@@ -1,125 +0,0 @@
"""
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
+51 -86
View File
@@ -150,55 +150,46 @@ setInterval(_refreshPendingDeployBanner, 30000);
</svg> </svg>
</div> </div>
</div> </div>
<div class="space-y-4 card-content" id="fleet-summary-content"> <div class="space-y-3 card-content" id="fleet-summary-content">
<!-- Seismographs --> <div class="flex justify-between items-center">
<div> <span class="text-gray-600 dark:text-gray-400">Total Units</span>
<div class="flex justify-between items-center mb-1.5"> <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="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 font-semibold text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a> <a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
</div> </div>
<span id="seismo-count" class="text-lg font-bold text-blue-600 dark:text-blue-400">--</span> <span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
</div> </div>
<div class="pl-6 flex flex-col gap-0.5 text-sm"> <div class="flex justify-between items-center mb-2">
<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 font-semibold text-gray-800 dark:text-gray-200 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a> <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>
</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">Call-in Status:</p> <p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed 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">
@@ -637,14 +628,9 @@ 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 .filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u));
&& u.coordinates
&& (u.device_type || 'seismograph') !== 'modem'
&& unitPassesFilter(u));
if (deployedUnits.length === 0) { if (deployedUnits.length === 0) {
return; return;
@@ -686,12 +672,10 @@ 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>
@@ -799,51 +783,32 @@ function updateDashboard(event) {
timeZoneName: 'short' timeZoneName: 'short'
}); });
// ===== Fleet Summary: per-device-type counts (always unfiltered) ===== // ===== Fleet summary numbers (always unfiltered) =====
// Deployed = unit has an active UnitAssignment (location_id set by document.getElementById('total-units').textContent = data.summary?.total ?? 0;
// the snapshot helper). Benched = no active assignment. document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
// Retired, out-for-calibration, and roster-unknown units (emitters document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
// not in the roster) are excluded from totals. document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
const counts = { document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
seismograph: { total: 0, deployed: 0, benched: 0 }, document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
sound_level_meter: { total: 0, deployed: 0, benched: 0 }, document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
};
let monitoredOk = 0, monitoredPending = 0, monitoredMissing = 0;
const unknownIds = new Set(Object.keys(data.unknown || {}));
Object.entries(data.units || {}).forEach(([uid, unit]) => { // ===== Device type counts (always unfiltered) =====
if (unit.retired || unit.out_for_calibration) return; let seismoCount = 0;
if (unknownIds.has(uid)) return; let slmCount = 0;
const dt = unit.device_type || 'seismograph'; let modemCount = 0;
const bucket = counts[dt]; Object.values(data.units || {}).forEach(unit => {
if (!bucket) return; // skip modems and anything else if (unit.retired) return; // Don't count retired units
const deviceType = unit.device_type || 'seismograph';
bucket.total++; if (deviceType === 'seismograph') {
if (unit.location_id) { seismoCount++;
bucket.deployed++; } else if (deviceType === 'sound_level_meter') {
} else { slmCount++;
bucket.benched++; } else if (deviceType === 'modem') {
} 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('seismo-count').textContent = counts.seismograph.total; document.getElementById('slm-count').textContent = slmCount;
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);
+35 -47
View File
@@ -129,15 +129,6 @@
<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>
@@ -648,12 +639,18 @@
{% include "partials/project_picker.html" with context %} {% include "partials/project_picker.html" with context %}
</div> </div>
<!-- Address / coordinates are managed on the project's <!-- Address -->
MonitoringLocation, not the unit itself. Edit them on <div>
the project page. --> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<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"> <input type="text" name="address" id="address" placeholder="123 Main St, City, State"
Address &amp; coordinates are set on the deployment location. 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">
Open the project to edit them. </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">
</div> </div>
</div> </div>
@@ -851,6 +848,16 @@
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">
@@ -1161,28 +1168,8 @@ function populateViewMode() {
if (projectLink) projectLink.classList.add('hidden'); if (projectLink) projectLink.classList.add('hidden');
} }
// Deployment Location — comes from the active UnitAssignment → document.getElementById('viewAddress').textContent = currentUnit.address || '--';
// MonitoringLocation. Show project link if present, otherwise document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
// "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 || '--';
@@ -1340,6 +1327,8 @@ 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' : '';
@@ -1620,13 +1609,8 @@ 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 — prefer the assignment's location name, fall // Update location text
// 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);
} }
@@ -1740,12 +1724,13 @@ async function uploadPhoto(file) {
const result = await response.json(); const result = await response.json();
// Show success message with metadata info. Location is on the // Show success message with metadata info
// 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.';
} }
@@ -1753,8 +1738,11 @@ 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 // Reload photos and unit data
await loadPhotos(); await loadPhotos();
if (result.coordinates_updated) {
await loadUnitData();
}
// Hide status after 5 seconds // Hide status after 5 seconds
setTimeout(() => { setTimeout(() => {