diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c9aa27..c2e48b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,79 @@ All notable changes to Terra-View will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.0] - 2026-05-17 + +Field-deployment workflow + fleet-wide deployment views + SFM event DB management. The headline is the mobile capture flow: a field tech can now arrive on site, take one photo of the installed seismograph, and walk away — classification (which project, which location) happens later at a desk through the new pending-deployment hopper. EXIF GPS is auto-extracted on capture, so the resulting `UnitAssignment` lands with coordinates without anyone typing them. + +### Added — field-deployment workflow + +- **`/deploy` — mobile-first 3-step capture wizard**: pick unit → take photo (opens phone camera via ``) → optional note → submit. Designed for under-90-seconds-on-site. Success page shows captured coords and links back to "Deploy another" or the pending hopper. +- **`/tools/pending-deployments` — the hopper**: filter pills Awaiting / Assigned / Cancelled. Each card has photo thumbnail, unit link, coords, operator note, status-appropriate actions. +- **Classify modal**: two modes — assign to existing project+location, OR create new location with new-or-existing project + a "use captured coords" checkbox that writes the pending row's coords onto the new location record. +- **`PendingDeployment` data model** (`pending_deployments` table): lifecycle `awaiting → assigned | cancelled`. Photo file lives under `data/photos/{unit_id}/install_YYYYMMDD_HHMMSS_.`. Migration: `backend/migrate_add_pending_deployments.py` (idempotent). +- **Backend endpoints**: + - `POST /api/deployments/capture` — multipart upload (unit_id, photo, optional note), EXIF GPS extraction, seismograph-only (rejects others with 400) + - `GET /api/deployments/pending` — list by status + - `GET /api/deployments/pending/{id}` — single row detail + - `POST /api/deployments/pending/{id}/promote` — classify and create `UnitAssignment`; events in the assignment window get retroactively attributed via the existing metadata-backfill mechanism + - `POST /api/deployments/pending/{id}/cancel` — abandon with optional reason + - `GET /api/deployments/seismograph-picker` — JSON list for the `/deploy` picker, annotated with `has_pending` +- **Discovery surfaces**: orange "Field Deploy" button on the desktop dashboard header (md+), bottom-nav slot 3 on mobile (Menu / Dashboard / Deploy / Events; Devices moved into the Menu drawer), `/tools` cards for both Field Deploy and Pending Deployments, dashboard banner that auto-shows when awaiting captures exist (polled every 30s, hides at 0). +- **Full audit trail**: every capture / promote / cancel writes a `UnitHistory` row (`pending_deployment_captured` / `_promoted` / `_cancelled`). + +### Added — fleet-wide deployment history + +- **`/tools/deployment-history` — fleet-wide 12-month calendar** (Phase 2 of the per-unit Gantt from v0.11.0). 4-month-per-row grid styled like the Job Planner, responsive to single column on mobile. Each day cell shows up to 4 deterministically-colored mini-bars (one per active project that day), with "+N" overflow. KPI strip across the top: project count, distinct unit count, total assignment count in the window. Collapsible project legend ordered by first-active date. +- **Click-a-day side panel**: slide-over from the right, groups by project, lists every (unit, location) active that day with auto-backfilled tags, sourced from new `GET /api/admin/deployment-history/day`. +- **Prev / Next / Recent month navigation**: shifts the 12-month window by 1 month. Default window is 11 months back from current → operator sees recent past on first load, not future emptiness. +- **Gantt by Project tab**: horizontal time-axis bars per project, hover for tooltip with unit + location + window. Reduced opacity for closed assignments, blue outline for metadata-backfilled, today dashed-orange line. +- **Gantt by Unit tab**: same idea inverted — one row per seismograph, bars colored by project. Natural for "where has BE11529 been across all my jobs?" Service layer returns a `units` array with bars carrying baked-in `project_color`. +- **Tab switcher with hash sync**: `#gantt` / `#byunit` preserved across month-paging. Tab registry (`_DH_TABS`) makes adding future views a one-line addition. + +### Added — SFM event DB management + +- **`/admin/events` — SFM Event DB Manager** under Developer Tools. Cross-unit event browser with filters (serial, from/to, false_trigger, limit), checkbox selection with select-all, and bulk actions: + - **Delete selected** — hard-delete chosen rows from SFM's `events` table + - **Delete ALL matching current filter** — dry-run first to show match count + sample serials in confirm dialog; only proceeds on explicit confirmation + - Same Flag-as-FT / Clear-FT bulk actions for convenience +- **Destructive operations also clean up on-disk files**: associated `.AB0*` blastware binary, `.a5.pkl`, `.sfm.json` sidecar, and `.h5` files are unlinked alongside the DB row. Cannot be undone — the manager has a prominent red warning banner and a `max_rows` safety cap (10,000) that refuses oversized deletes without explicit acknowledgment. +- **Designed for cleaning bogus events from a misbehaving unit** — a stuck-triggered seismograph can dump hundreds of junk events into SFM before it's recovered; this is the operator's broom. +- **`unit_detail.html` also gains bulk false-trigger flagging**: same checkbox UX as the DB manager, but with **🚩 Flag as false trigger** / **✓ Clear false trigger** instead of delete (delete is admin-only via `/admin/events`). Concurrent fan-out (8 in flight) for fast bulk PATCH. + +### Added — maps, navigation, polish + +- **Reusable location-map partial** (`templates/partials/projects/location_map.html`): self-contained map div + self-fetch script. Accepts `project_id`, `map_height`, `location_type` filter. Project overview's inline map (~150 lines of JS) replaced with a 1-line include; Vibration tab on the project detail page now uses the same partial with `location_type='vibration'` at 450px height. +- **Hover location card → highlight matching map pin** on the project overview map. Enlarges + reddens the pin, opens its tooltip. Bidirectional with the existing pin → card flash. Event delegation on `document` so cards from htmx swaps keep the behavior without rewiring. +- **Mobile bottom-nav swap Settings → Events**: Settings (rarely needed in the field) replaced by Events (the daily mobile destination since the SFM integration). Settings/Projects/Tools/admin pages still in the Menu drawer. + +### Fixed + +- **`/deploy` photo input now allows gallery picks**: `capture="environment"` was forcing mobile browsers to open the camera and skip "Photo Library" / "Choose File". Useful at the install site, problematic when uploading a photo taken earlier. Attribute removed; chooser now offers both options. EXIF extraction works identically. + +### Migration Notes + +One new migration this release. Idempotent and non-destructive. + +```bash +docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_pending_deployments.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 table: `pending_deployments` — capture rows for the field-deployment workflow. Empty after migration; populated as field techs use `/deploy`. + +**Deploy order matters**: run the migration BEFORE the new code is up, or the running app will 500 on the missing table. Same gotcha that bit the v0.10.0 → v0.11.0 deploy. + +**SFM bump pairing**: this release pairs with seismo-relay v0.17.0, which adds the `DELETE /db/events/{id}` and `POST /db/events/delete_bulk` endpoints that `/admin/events` consumes. An older SFM will return 405/404 for those routes; the manager will surface the error in its result alert. + +--- + ## [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. diff --git a/README.md b/README.md index 4a74e14..6a5a3b5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Terra-View v0.11.0 +# Terra-View v0.12.0 Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. ## Features diff --git a/backend/main.py b/backend/main.py index f88e18c..8247664 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine) ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app -VERSION = "0.11.0" +VERSION = "0.12.0" if ENVIRONMENT == "development": _build = os.getenv("BUILD_NUMBER", "0") if _build and _build != "0": @@ -109,6 +109,12 @@ app.include_router(watcher_manager.router) from backend.routers import admin_modules app.include_router(admin_modules.router) +from backend.routers import deployment_history +app.include_router(deployment_history.router) + +from backend.routers import pending_deployments +app.include_router(pending_deployments.router) + # Projects system routers app.include_router(projects.router) app.include_router(project_locations.router) @@ -269,6 +275,22 @@ async def tools_page(request: Request): return templates.TemplateResponse("tools.html", {"request": request}) +@app.get("/deploy", response_class=HTMLResponse) +async def deploy_page(request: Request): + """Mobile-first field-capture wizard. Pick a seismograph, snap a + photo of the install, optionally add a memo — drop into the pending + hopper for later classification.""" + return templates.TemplateResponse("deploy.html", {"request": request}) + + +@app.get("/tools/pending-deployments", response_class=HTMLResponse) +async def pending_deployments_page(request: Request): + """List of field captures awaiting classification, plus filters for + historical assigned / cancelled rows. Operators promote a capture + into a real UnitAssignment from here.""" + return templates.TemplateResponse("admin/pending_deployments.html", {"request": request}) + + @app.get("/modems", response_class=HTMLResponse) async def modems_page(request: Request): """Field modems management dashboard""" diff --git a/backend/migrate_add_pending_deployments.py b/backend/migrate_add_pending_deployments.py new file mode 100644 index 0000000..d4a767e --- /dev/null +++ b/backend/migrate_add_pending_deployments.py @@ -0,0 +1,75 @@ +""" +Migration: add `pending_deployments` table. + +Stores "I just installed this seismograph" captures from the field. +A pending deployment is the prospective form of a UnitAssignment — +captured at install time (photo + coords + maybe a free-text note), +classified later (project + location chosen at a desk). + +Once classified, a real UnitAssignment is created, the pending row's +status flips to "assigned", and resulting_assignment_id points at the +new assignment for audit. + +Idempotent — safe to re-run. Non-destructive — adds only. + +Run with: + docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_pending_deployments.py +""" + +import os +import sqlite3 + +DB_PATH = "./data/seismo_fleet.db" + + +def migrate_database() -> None: + if not os.path.exists(DB_PATH): + print(f"Database not found at {DB_PATH}") + return + + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + cur.execute(""" + CREATE TABLE IF NOT EXISTS pending_deployments ( + id TEXT PRIMARY KEY, + unit_id TEXT NOT NULL, + captured_at DATETIME NOT NULL, + coordinates TEXT, + operator_note TEXT, + photo_filename TEXT, + status TEXT NOT NULL DEFAULT 'awaiting', + promoted_at DATETIME, + resulting_assignment_id TEXT, + cancelled_at DATETIME, + cancelled_reason TEXT, + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + updated_at DATETIME NOT NULL DEFAULT (datetime('now')) + ) + """) + print(" Table 'pending_deployments' ready.") + + # Indexes — operators will query by status (hopper list) and by + # unit_id (per-unit detail page → "is there a pending capture?"). + cur.execute(""" + CREATE INDEX IF NOT EXISTS idx_pending_deployments_status + ON pending_deployments (status) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS idx_pending_deployments_unit_id + ON pending_deployments (unit_id) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS idx_pending_deployments_captured_at + ON pending_deployments (captured_at) + """) + print(" Indexes ready.") + + conn.commit() + conn.close() + + +if __name__ == "__main__": + print("Running migration: add pending_deployments table") + migrate_database() + print("Done.") diff --git a/backend/models.py b/backend/models.py index 380656b..3ba1e11 100644 --- a/backend/models.py +++ b/backend/models.py @@ -644,3 +644,58 @@ class JobReservationUnit(Base): # Location identity location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance" slot_index = Column(Integer, nullable=True) # Order within reservation (0-based) + + +class PendingDeployment(Base): + """ + Field-captured "I just installed this seismograph" record waiting + to be classified into a project + location. + + Lifecycle: + 1. Operator captures from the /deploy mobile page — photo (EXIF + GPS auto-extracted), optional free-text note. Row created + with status="awaiting". + 2. Later, at a desk: operator picks a project + location (existing + or new) and "promotes" the row. A real UnitAssignment is + created, this row's status flips to "assigned", and + resulting_assignment_id points at the new assignment. + 3. Mistakes / abandoned captures → status="cancelled" with a + cancelled_reason for audit. + + Events emitted by the unit before classification are NOT auto- + attributed (no UnitAssignment exists yet). They land in the + "unattributed" bucket on the unit's events tab. Once the pending + deployment is promoted, the new UnitAssignment's window + retroactively attributes them — same mechanism the metadata- + backfill tool uses. + + Seismograph-only for v1. SLM deployments don't follow the same + "field-install + verify call-home" pattern and are tracked + elsewhere. + """ + __tablename__ = "pending_deployments" + + id = Column(String, primary_key=True, index=True) # UUID + unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit + captured_at = Column(DateTime, nullable=False) # When the photo was taken + coordinates = Column(String, nullable=True) # "lat,lon" from photo EXIF + operator_note = Column(Text, nullable=True) # Free text — site memo + + # Path under data/photos/{unit_id}/. Just the filename; the unit + # context lives in unit_id. + photo_filename = Column(String, nullable=True) + + # Lifecycle. + # "awaiting" — captured, not yet classified + # "assigned" — promoted to a UnitAssignment + # "cancelled" — operator marked it as a mistake / abandoned + status = Column(String, nullable=False, default="awaiting", index=True) + + promoted_at = Column(DateTime, nullable=True) + resulting_assignment_id = Column(String, nullable=True) # FK to UnitAssignment when promoted + + cancelled_at = Column(DateTime, nullable=True) + cancelled_reason = Column(Text, nullable=True) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/routers/admin_modules.py b/backend/routers/admin_modules.py index 8f9dc6c..833c621 100644 --- a/backend/routers/admin_modules.py +++ b/backend/routers/admin_modules.py @@ -49,6 +49,15 @@ def admin_sfm_page(request: Request): }) +@router.get("/admin/events", response_class=HTMLResponse) +def admin_events_page(request: Request): + """SFM Event DB Manager — browse, flag, and delete events across all units.""" + return templates.TemplateResponse("admin_events.html", { + "request": request, + "sfm_base_url": SFM_BASE_URL, + }) + + @router.get("/api/admin/sfm/overview") async def admin_sfm_overview() -> JSONResponse: """Aggregated SFM diagnostic snapshot. diff --git a/backend/routers/deployment_history.py b/backend/routers/deployment_history.py new file mode 100644 index 0000000..2beece5 --- /dev/null +++ b/backend/routers/deployment_history.py @@ -0,0 +1,99 @@ +""" +Fleet-wide deployment-history calendar — Phase 2 of the +deployment-history visualisation work (Phase 1 is the per-unit Gantt +on /unit/{id}). + +Renders all UnitAssignment windows across all projects on a 12-month +calendar grid styled like the Job Planner. Each day cell shows one +mini-bar per project that had ≥1 active assignment that day. Click a +day → side panel with the (unit, location) pairs active. + +Routes: + GET /tools/deployment-history — HTML page + GET /api/admin/deployment-history/day — JSON list of deployments + on a specific date (used + by the day-detail panel) +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import HTMLResponse, JSONResponse +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.services.deployment_history import ( + get_deployment_history_data, + get_deployments_on_day, +) +from backend.templates_config import templates + +router = APIRouter() + + +@router.get("/tools/deployment-history", response_class=HTMLResponse) +def deployment_history_page( + request: Request, + year: Optional[int] = Query(None), + month: Optional[int] = Query(None), + db: Session = Depends(get_db), +): + """Fleet-wide deployment history calendar. + + Defaults to a 12-month window ending in the current month (so the + operator sees the recent past, not the future). ?year=&month= can + override the START of the window to scroll backward or forward. + """ + today = date.today() + # Default: 12-month window ending this month → start = 11 months back. + if year is None or month is None: + # 11 months back from current month. + m = today.month - 11 + y = today.year + while m < 1: + m += 12 + y -= 1 + start_year, start_month = y, m + else: + start_year, start_month = year, month + + calendar = get_deployment_history_data(db, start_year, start_month) + + # Build prev/next navigation values. + prev_y, prev_m = (start_year - 1, 12) if start_month == 1 else (start_year, start_month - 1) + next_y, next_m = (start_year + 1, 1) if start_month == 12 else (start_year, start_month + 1) + + return templates.TemplateResponse("admin/deployment_history.html", { + "request": request, + "calendar": calendar, + "today": today.isoformat(), + "prev_year": prev_y, + "prev_month": prev_m, + "next_year": next_y, + "next_month": next_m, + }) + + +@router.get("/api/admin/deployment-history/day") +def deployment_history_day( + target_date: str = Query(..., description="YYYY-MM-DD"), + db: Session = Depends(get_db), +): + """Return assignments active on a specific calendar day.""" + try: + d = date.fromisoformat(target_date) + except ValueError: + return JSONResponse( + {"error": f"Invalid date: {target_date!r}"}, + status_code=400, + ) + + deployments = get_deployments_on_day(db, d) + return JSONResponse({ + "date": target_date, + "count": len(deployments), + "deployments": deployments, + }) diff --git a/backend/routers/pending_deployments.py b/backend/routers/pending_deployments.py new file mode 100644 index 0000000..06cf2e7 --- /dev/null +++ b/backend/routers/pending_deployments.py @@ -0,0 +1,452 @@ +""" +Pending deployments — field-captured "I just installed this seismograph" +records waiting to be classified into a project + location. + +Routes: + POST /api/deployments/capture — capture a new pending deployment + GET /api/deployments/pending — list awaiting captures + GET /api/deployments/pending/{id} — single capture detail + POST /api/deployments/pending/{id}/promote — classify → create UnitAssignment + POST /api/deployments/pending/{id}/cancel — abandon + +See backend/models.py PendingDeployment docstring for the full lifecycle. + +Seismograph-only for v1; capture refuses if unit_id is anything else. +""" + +from __future__ import annotations + +import shutil +import uuid +from datetime import datetime +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.models import ( + PendingDeployment, + RosterUnit, + Project, + MonitoringLocation, + UnitAssignment, + UnitHistory, +) +from backend.routers.photos import extract_exif_data + +router = APIRouter(prefix="/api/deployments", tags=["pending-deployments"]) + +PHOTOS_BASE_DIR = Path("data/photos") +_ALLOWED_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"} + + +def _record_history( + db: Session, + unit_id: str, + change_type: str, + *, + old_value: Optional[str] = None, + new_value: Optional[str] = None, + notes: Optional[str] = None, + source: str = "manual", +) -> None: + """Mirror of project_locations._record_assignment_history — kept local + so this router doesn't depend on a project_locations import cycle.""" + db.add(UnitHistory( + unit_id=unit_id, + change_type=change_type, + field_name="pending_deployment", + old_value=old_value, + new_value=new_value, + changed_at=datetime.utcnow(), + source=source, + notes=notes, + )) + + +@router.get("/seismograph-picker") +def seismograph_picker( + q: str = "", + limit: int = 20, + db: Session = Depends(get_db), +): + """JSON list of seismograph units for the /deploy mobile picker. + + Filters out retired units. Sorts by recency of pending captures + first, then alphabetically — so units the operator is actively + deploying with surface at the top. + """ + q_clean = (q or "").strip() + qb = db.query(RosterUnit).filter( + RosterUnit.device_type == "seismograph", + RosterUnit.retired == False, # noqa: E712 + ) + if q_clean: + qb = qb.filter( + (RosterUnit.id.ilike(f"%{q_clean}%")) + | (RosterUnit.note.ilike(f"%{q_clean}%")) + ) + units = qb.order_by(RosterUnit.id).limit(limit).all() + + # Annotate with "has an awaiting pending deployment" so the picker + # can de-emphasize / warn on units that are already mid-deploy. + pending_unit_ids = { + r[0] for r in db.query(PendingDeployment.unit_id) + .filter_by(status="awaiting").distinct().all() + } + + return { + "units": [ + { + "id": u.id, + "note": u.note, + "deployed": u.deployed, + "has_pending": u.id in pending_unit_ids, + } + for u in units + ], + } + + +@router.post("/capture") +async def capture_deployment( + unit_id: str = Form(...), + operator_note: str = Form(""), + captured_at_iso: str = Form(""), + photo: UploadFile = File(...), + db: Session = Depends(get_db), +): + """Field-capture endpoint. + + Multipart form: + unit_id — seismograph being deployed + operator_note — optional free-text site memo + captured_at_iso — optional override of the capture timestamp + (default: photo's EXIF DateTimeOriginal, or now) + photo — install photo (EXIF GPS extracted if present) + + Refuses if unit_id isn't a seismograph (SLM deployments don't follow + the same field-install pattern). + """ + unit = db.query(RosterUnit).filter_by(id=unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail=f"Unit {unit_id!r} not found.") + if unit.device_type != "seismograph": + raise HTTPException( + status_code=400, + detail=f"Pending deployments are for seismographs only " + f"(this unit is {unit.device_type}).", + ) + + # Validate + save the photo. + file_ext = Path(photo.filename or "photo.jpg").suffix.lower() + if file_ext not in _ALLOWED_PHOTO_EXTS: + raise HTTPException( + status_code=400, + detail=f"Invalid photo type {file_ext!r}. Allowed: {sorted(_ALLOWED_PHOTO_EXTS)}", + ) + + unit_photo_dir = PHOTOS_BASE_DIR / unit_id + unit_photo_dir.mkdir(parents=True, exist_ok=True) + + capture_id = str(uuid.uuid4()) + ts_str = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"install_{ts_str}_{capture_id[:8]}{file_ext}" + file_path = unit_photo_dir / filename + + try: + with open(file_path, "wb") as buf: + shutil.copyfileobj(photo.file, buf) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to save photo: {e}") + + # Extract EXIF — best-effort. No EXIF / no GPS is fine; operator + # can fill coordinates manually later in the promote step. + metadata = extract_exif_data(file_path) + coords = metadata.get("coordinates") # "lat,lon" or None + photo_ts = metadata.get("timestamp") # datetime or None + + if captured_at_iso: + try: + captured_at = datetime.fromisoformat(captured_at_iso) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid captured_at_iso: {captured_at_iso!r}") + elif photo_ts: + captured_at = photo_ts + else: + captured_at = datetime.utcnow() + + pd = PendingDeployment( + id = capture_id, + unit_id = unit_id, + captured_at = captured_at, + coordinates = coords, + operator_note = (operator_note or "").strip() or None, + photo_filename = filename, + status = "awaiting", + ) + db.add(pd) + + _record_history( + db, unit_id=unit_id, + change_type="pending_deployment_captured", + new_value=f"awaiting classification @ {captured_at:%Y-%m-%d %H:%M}" + + (f" • {coords}" if coords else ""), + notes=(operator_note or None), + ) + + db.commit() + db.refresh(pd) + + return JSONResponse({ + "success": True, + "pending_deployment": _to_dict(pd, unit=unit), + "photo_url": f"/api/unit/{unit_id}/photo/{filename}", + "extracted_coords": coords, + "extracted_timestamp": photo_ts.isoformat() if photo_ts else None, + }) + + +@router.get("/pending") +def list_pending( + status: str = "awaiting", + db: Session = Depends(get_db), +): + """List pending deployments by status (default: awaiting classification).""" + rows = ( + db.query(PendingDeployment) + .filter_by(status=status) + .order_by(PendingDeployment.captured_at.desc()) + .all() + ) + # Bulk-resolve unit references in one query (avoid N+1). + unit_ids = {r.unit_id for r in rows} + units = {u.id: u for u in db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()} \ + if unit_ids else {} + return { + "count": len(rows), + "status": status, + "pending_deployments": [_to_dict(r, unit=units.get(r.unit_id)) for r in rows], + } + + +@router.get("/pending/{pending_id}") +def get_pending(pending_id: str, db: Session = Depends(get_db)): + pd = db.query(PendingDeployment).filter_by(id=pending_id).first() + if not pd: + raise HTTPException(status_code=404, detail="Pending deployment not found.") + unit = db.query(RosterUnit).filter_by(id=pd.unit_id).first() + return _to_dict(pd, unit=unit, detail=True) + + +@router.post("/pending/{pending_id}/promote") +async def promote_pending( + pending_id: str, + request: Request, + db: Session = Depends(get_db), +): + """Classify a pending deployment → create a UnitAssignment. + + Body JSON — one of two shapes: + + 1. Assign to existing location: + { + "location_id": "", + "notes": "" + } + + 2. Create a new location under (existing or new) project: + { + "project_id": "" | null, # null means create new + "project_name": "", + "project_type_id": "", + # required if creating new project + "location_name": "", + "use_captured_coords": true | false, # default true — write the + # pending's coordinates onto + # the new location + "notes": "" + } + + Status flips to "assigned"; resulting_assignment_id is populated. + """ + pd = db.query(PendingDeployment).filter_by(id=pending_id).first() + if not pd: + raise HTTPException(status_code=404, detail="Pending deployment not found.") + if pd.status != "awaiting": + raise HTTPException( + status_code=400, + detail=f"Pending deployment is {pd.status!r}, not awaiting — already classified?", + ) + + try: + payload = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body.") + + notes = (payload.get("notes") or "").strip() or None + + # Resolve / create the location. + location_id = payload.get("location_id") + if location_id: + location = db.query(MonitoringLocation).filter_by(id=location_id).first() + if not location: + raise HTTPException(status_code=404, detail=f"Location {location_id!r} not found.") + project_id = location.project_id + else: + # Create-new path. Need a project (existing or new). + project_id = payload.get("project_id") + if not project_id: + project_name = (payload.get("project_name") or "").strip() + project_type_id = (payload.get("project_type_id") or "").strip() + if not project_name: + raise HTTPException( + status_code=400, + detail="Either project_id, or project_name + project_type_id, required.", + ) + if not project_type_id: + raise HTTPException( + status_code=400, + detail="project_type_id required when creating a new project.", + ) + new_project = Project( + id=str(uuid.uuid4()), + name=project_name, + project_type_id=project_type_id, + status="active", + ) + db.add(new_project) + db.flush() + project_id = new_project.id + + loc_name = (payload.get("location_name") or "").strip() + if not loc_name: + raise HTTPException(status_code=400, detail="location_name required.") + use_coords = payload.get("use_captured_coords", True) + location = MonitoringLocation( + id=str(uuid.uuid4()), + project_id=project_id, + location_type="vibration", # seismographs only + name=loc_name, + coordinates=(pd.coordinates if use_coords else None), + ) + db.add(location) + db.flush() + + # Create the assignment. assigned_at = pending capture time (so + # events emitted after the install are correctly attributed back + # to this location). + assignment = UnitAssignment( + id=str(uuid.uuid4()), + unit_id=pd.unit_id, + location_id=location.id, + project_id=project_id, + device_type="seismograph", + assigned_at=pd.captured_at, + assigned_until=None, + status="active", + notes=notes, + source="manual", + ) + db.add(assignment) + db.flush() + + # Promote the pending row. + pd.status = "assigned" + pd.promoted_at = datetime.utcnow() + pd.resulting_assignment_id = assignment.id + pd.updated_at = datetime.utcnow() + + _record_history( + db, unit_id=pd.unit_id, + change_type="pending_deployment_promoted", + old_value="awaiting", + new_value=f"{location.name} (assignment {assignment.id[:8]})", + notes=notes, + ) + + db.commit() + db.refresh(pd) + db.refresh(assignment) + + return { + "success": True, + "assignment_id": assignment.id, + "location_id": location.id, + "project_id": project_id, + "promoted_at": pd.promoted_at.isoformat(), + } + + +@router.post("/pending/{pending_id}/cancel") +async def cancel_pending( + pending_id: str, + request: Request, + db: Session = Depends(get_db), +): + """Mark a pending deployment as cancelled (operator captured by mistake).""" + pd = db.query(PendingDeployment).filter_by(id=pending_id).first() + if not pd: + raise HTTPException(status_code=404, detail="Pending deployment not found.") + if pd.status != "awaiting": + raise HTTPException( + status_code=400, + detail=f"Pending deployment is {pd.status!r}, not awaiting.", + ) + + try: + payload = await request.json() + except Exception: + payload = {} + reason = (payload.get("reason") or "").strip() or None + + pd.status = "cancelled" + pd.cancelled_at = datetime.utcnow() + pd.cancelled_reason = reason + pd.updated_at = datetime.utcnow() + + _record_history( + db, unit_id=pd.unit_id, + change_type="pending_deployment_cancelled", + old_value="awaiting", + new_value="cancelled", + notes=reason, + ) + + db.commit() + return {"success": True, "cancelled_at": pd.cancelled_at.isoformat()} + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _to_dict(pd: PendingDeployment, unit: Optional[RosterUnit] = None, detail: bool = False) -> dict: + out = { + "id": pd.id, + "unit_id": pd.unit_id, + "captured_at": pd.captured_at.isoformat() if pd.captured_at else None, + "coordinates": pd.coordinates, + "operator_note": pd.operator_note, + "photo_filename": pd.photo_filename, + "photo_url": f"/api/unit/{pd.unit_id}/photo/{pd.photo_filename}" + if pd.photo_filename else None, + "status": pd.status, + "created_at": pd.created_at.isoformat() if pd.created_at else None, + } + if pd.status == "assigned": + out["promoted_at"] = pd.promoted_at.isoformat() if pd.promoted_at else None + out["resulting_assignment_id"] = pd.resulting_assignment_id + if pd.status == "cancelled": + out["cancelled_at"] = pd.cancelled_at.isoformat() if pd.cancelled_at else None + out["cancelled_reason"] = pd.cancelled_reason + + if unit: + out["unit"] = { + "id": unit.id, + "device_type": unit.device_type, + "note": unit.note, + "deployed": unit.deployed, + } + return out diff --git a/backend/services/deployment_history.py b/backend/services/deployment_history.py new file mode 100644 index 0000000..0186eb8 --- /dev/null +++ b/backend/services/deployment_history.py @@ -0,0 +1,387 @@ +""" +Deployment-history calendar service — builds the data structure for the +fleet-wide deployment-history grid (`/tools/deployment-history`). + +For each calendar day in a 12-month window, computes which projects had +at least one unit assigned to a location on that day. Renders as +multi-month grid (job-planner style) with project-colored bars per day. + +Distinct from `services/fleet_calendar_service.py` which renders +forward-looking RESERVATIONS for the planner. This one is purely +historical / current — it walks `unit_assignments` instead of +`job_reservations`. +""" + +from __future__ import annotations + +import hashlib +from datetime import date, datetime, timedelta +from calendar import monthrange +from typing import Optional + +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ + +from backend.models import Project, UnitAssignment + + +# Color palette for projects without an explicit color attribute. Chosen +# to have decent contrast on both light and dark backgrounds; cycles +# deterministically by SHA1(project_id). +_PROJECT_COLOR_PALETTE = [ + "#f48b1c", "#142a66", "#7d234d", "#0e7490", "#15803d", + "#a16207", "#9333ea", "#dc2626", "#0d9488", "#1d4ed8", + "#be185d", "#65a30d", "#0891b2", "#7c3aed", "#b91c1c", +] + + +def _color_for_project(project_id: str) -> str: + """Deterministic color assignment from a fixed palette.""" + h = hashlib.sha1(project_id.encode("utf-8")).digest()[0] + return _PROJECT_COLOR_PALETTE[h % len(_PROJECT_COLOR_PALETTE)] + + +def _month_short(m: int) -> str: + return ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m - 1] + + +def _month_full(m: int) -> str: + return ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"][m - 1] + + +def get_deployment_history_data( + db: Session, + start_year: int, + start_month: int, +) -> dict: + """ + Build the calendar data structure for a 12-month window starting at + (start_year, start_month). + + Returns: + { + "months": [ + { + "year": int, + "month": int, # 1-12 + "name": "January", + "short_name": "Jan", + "year_short": "26", + "num_days": int, + "first_weekday": int, # 0=Mon..6=Sun (datetime.weekday()) + "active_days": { + day_num: [project_id, project_id, ...] # projects with + # ≥1 active assignment + # on that day + }, + }, + ... # 12 entries + ], + "projects": [ + { + "id": str, + "name": str, + "color": str, + "status": str, + "client_name": str | None, + "assignment_count": int, # total assignments contributing to + # this 12-month window + "first_active": "YYYY-MM-DD" | None, + "last_active": "YYYY-MM-DD" | None, + }, + ... # only projects with + # ≥1 assignment in the + # window, sorted by + # first_active ASC + ], + "total_assignments": int, + "total_active_units": int, # distinct unit_ids across the window + "window": { + "start_year": int, + "start_month": int, + "end_year": int, + "end_month": int, + "first_date": "YYYY-MM-DD", + "last_date": "YYYY-MM-DD", + }, + } + """ + # Compute window edges. + first_date = date(start_year, start_month, 1) + # 12 months → end on day-1 of (start + 12) + end_year = start_year + ((start_month + 10) // 12) + end_month = ((start_month + 10) % 12) + 1 + last_date = date(end_year, end_month, monthrange(end_year, end_month)[1]) + + now = datetime.utcnow() + + # Fetch every assignment that overlaps the window. An assignment + # overlaps if assigned_at <= last_date AND (assigned_until is NULL + # OR assigned_until >= first_date). + assignments = ( + db.query(UnitAssignment) + .filter(UnitAssignment.assigned_at <= datetime.combine(last_date, datetime.max.time())) + .filter( + or_( + UnitAssignment.assigned_until == None, # noqa: E711 — active + UnitAssignment.assigned_until >= datetime.combine(first_date, datetime.min.time()), + ) + ) + .all() + ) + + # Resolve referenced projects in one query. + proj_ids = {a.project_id for a in assignments} + proj_map = { + p.id: p for p in db.query(Project).filter(Project.id.in_(proj_ids)).all() + } if proj_ids else {} + + # Resolve location names in one batch query (used by the Gantt view + # for per-bar tooltips). + from backend.models import MonitoringLocation + loc_ids = {a.location_id for a in assignments} + loc_name_map = { + l.id: l.name for l in db.query(MonitoringLocation).filter( + MonitoringLocation.id.in_(loc_ids) + ).all() + } if loc_ids else {} + + # Compute "active days per project" by walking each assignment and + # adding every day in its [start, end] ∩ [first_date, last_date]. + # O(N_assignments × avg_window_days); for a typical fleet this is + # bounded (hundreds of assignments × hundreds of days = manageable). + # Also collect raw per-assignment bar data for the Gantt view. + project_active_days: dict[str, set[date]] = {} + project_first_active: dict[str, date] = {} + project_last_active: dict[str, date] = {} + project_assignment_count: dict[str, int] = {} + project_bars: dict[str, list[dict]] = {} + distinct_units: set[str] = set() + + for a in assignments: + start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date) + end_dt = a.assigned_until or now + end = min(end_dt.date(), last_date) + if end < start: + continue + days = project_active_days.setdefault(a.project_id, set()) + d = start + while d <= end: + days.add(d) + d += timedelta(days=1) + project_assignment_count[a.project_id] = project_assignment_count.get(a.project_id, 0) + 1 + distinct_units.add(a.unit_id) + # Track first/last active dates in the window. + prev_first = project_first_active.get(a.project_id) + if prev_first is None or start < prev_first: + project_first_active[a.project_id] = start + prev_last = project_last_active.get(a.project_id) + if prev_last is None or end > prev_last: + project_last_active[a.project_id] = end + + # Per-assignment bar data — used by the Gantt view's renderer. + # `is_active` reflects whether the assignment_until was still NULL + # at fetch time (open-ended deployment); the clipped `end` here + # is just for visual bar drawing. + project_bars.setdefault(a.project_id, []).append({ + "unit_id": a.unit_id, + "location_id": a.location_id, + "location_name": loc_name_map.get(a.location_id, "(unknown location)"), + "start": start.isoformat(), + "end": end.isoformat(), + "is_active": a.assigned_until is None, + "source": a.source, + }) + + # Build the projects array (sorted by first_active ascending so the + # legend reads in deployment-order). + projects_data = [] + for pid, days in project_active_days.items(): + p = proj_map.get(pid) + if not p: + # Assignment references a deleted project — surface it anyway + # with a placeholder name, since the bars still need a label. + projects_data.append({ + "id": pid, + "name": "(deleted project)", + "color": _color_for_project(pid), + "status": "deleted", + "client_name": None, + "assignment_count": project_assignment_count.get(pid, 0), + "first_active": project_first_active[pid].isoformat() if pid in project_first_active else None, + "last_active": project_last_active[pid].isoformat() if pid in project_last_active else None, + "bars": project_bars.get(pid, []), + }) + continue + projects_data.append({ + "id": pid, + "name": p.name, + "color": _color_for_project(pid), + "status": p.status or "active", + "client_name": p.client_name, + "assignment_count": project_assignment_count.get(pid, 0), + "first_active": project_first_active[pid].isoformat() if pid in project_first_active else None, + "last_active": project_last_active[pid].isoformat() if pid in project_last_active else None, + "bars": project_bars.get(pid, []), + }) + + projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"])) + + # ── Per-unit view data (Gantt-by-Unit tab) ──────────────────────── + # Same source assignments, re-grouped by unit_id. Each bar carries + # the project's color + name so the renderer can paint by job + # without doing a second lookup. + unit_bars: dict[str, list[dict]] = {} + project_lookup = {p["id"]: p for p in projects_data} + for a in assignments: + start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date) + end_dt = a.assigned_until or now + end = min(end_dt.date(), last_date) + if end < start: + continue + p_info = project_lookup.get(a.project_id, {}) + unit_bars.setdefault(a.unit_id, []).append({ + "project_id": a.project_id, + "project_name": p_info.get("name", "(deleted project)"), + "project_color": p_info.get("color", _color_for_project(a.project_id)), + "location_id": a.location_id, + "location_name": loc_name_map.get(a.location_id, "(unknown location)"), + "start": start.isoformat(), + "end": end.isoformat(), + "is_active": a.assigned_until is None, + "source": a.source, + }) + + # Sort units by first-active date so the most-recently-deployed + # units sit at the top. Reverse if we want oldest-first. + units_data = [] + for uid, bars in unit_bars.items(): + bars.sort(key=lambda b: b["start"]) + first_start = bars[0]["start"] + # "active now" flag = any bar is still active + any_active = any(b["is_active"] for b in bars) + units_data.append({ + "id": uid, + "bars": bars, + "first_active": first_start, + "assignment_count": len(bars), + "any_active": any_active, + }) + units_data.sort(key=lambda u: (not u["any_active"], u["first_active"], u["id"])) + + # Now build the months array. + months_data = [] + cur_year, cur_month = start_year, start_month + for _ in range(12): + num_days = monthrange(cur_year, cur_month)[1] + first_weekday = date(cur_year, cur_month, 1).weekday() # 0=Mon..6=Sun + active_days: dict[int, list[str]] = {} + for day_num in range(1, num_days + 1): + d = date(cur_year, cur_month, day_num) + day_projects = [ + pid for pid, days in project_active_days.items() + if d in days + ] + if day_projects: + # Sort by the project's color-stable order so bars don't + # jitter between days. + day_projects.sort() + active_days[day_num] = day_projects + months_data.append({ + "year": cur_year, + "month": cur_month, + "name": _month_full(cur_month), + "short_name": _month_short(cur_month), + "year_short": f"{cur_year % 100:02d}", + "num_days": num_days, + "first_weekday": first_weekday, + "active_days": active_days, + }) + # Advance one month. + if cur_month == 12: + cur_year += 1 + cur_month = 1 + else: + cur_month += 1 + + return { + "months": months_data, + "projects": projects_data, + "units": units_data, + "total_assignments": len(assignments), + "total_active_units": len(distinct_units), + "window": { + "start_year": start_year, + "start_month": start_month, + "end_year": end_year, + "end_month": end_month, + "first_date": first_date.isoformat(), + "last_date": last_date.isoformat(), + }, + } + + +def get_deployments_on_day( + db: Session, + target_date: date, +) -> list[dict]: + """ + Return the list of (unit, location, project) tuples that were + actively assigned on a specific calendar date. Used for the + day-detail side panel when an operator clicks a day cell. + """ + from backend.models import MonitoringLocation, RosterUnit + + day_start = datetime.combine(target_date, datetime.min.time()) + day_end = datetime.combine(target_date, datetime.max.time()) + + rows = ( + db.query(UnitAssignment) + .filter(UnitAssignment.assigned_at <= day_end) + .filter( + or_( + UnitAssignment.assigned_until == None, # noqa: E711 + UnitAssignment.assigned_until >= day_start, + ) + ) + .order_by(UnitAssignment.project_id, UnitAssignment.unit_id) + .all() + ) + + if not rows: + return [] + + loc_ids = {a.location_id for a in rows} + proj_ids = {a.project_id for a in rows} + loc_map = { + l.id: l for l in db.query(MonitoringLocation).filter( + MonitoringLocation.id.in_(loc_ids) + ).all() + } + proj_map = { + p.id: p for p in db.query(Project).filter( + Project.id.in_(proj_ids) + ).all() + } + + results = [] + for a in rows: + loc = loc_map.get(a.location_id) + proj = proj_map.get(a.project_id) + results.append({ + "assignment_id": a.id, + "unit_id": a.unit_id, + "location_id": a.location_id, + "location_name": loc.name if loc else "(unknown location)", + "project_id": a.project_id, + "project_name": proj.name if proj else "(deleted project)", + "project_color": _color_for_project(a.project_id), + "assigned_at": a.assigned_at.isoformat() if a.assigned_at else None, + "assigned_until": a.assigned_until.isoformat() if a.assigned_until else None, + "is_active": a.assigned_until is None, + "source": a.source, + }) + + return results diff --git a/templates/admin/deployment_history.html b/templates/admin/deployment_history.html new file mode 100644 index 0000000..e80b6a8 --- /dev/null +++ b/templates/admin/deployment_history.html @@ -0,0 +1,711 @@ +{% extends "base.html" %} + +{% block title %}Deployment History - Seismo Fleet Manager{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+
+ ← Back to Tools +

Deployment History

+

+ Where every unit has been — actual assignment windows, color-coded by project. + For future / planned deployments use the Job Planner. +

+
+
+ + +
+ + + +
+ + +
+
+ {{ calendar.projects | length }} project{{ '' if calendar.projects | length == 1 else 's' }} + | + {{ calendar.total_active_units }} unique units + | + {{ calendar.total_assignments }} assignment{{ '' if calendar.total_assignments == 1 else 's' }} in window +
+ {% if calendar.projects %} +
+ Project legend +
+ {% for p in calendar.projects %} + + + {{ p.name }} + · + {{ p.assignment_count }} + + {% endfor %} +
+
+ {% endif %} +
+ + +
+ + +{% if calendar.projects %} +
+ {% for month_data in calendar.months %} +
+

+ {{ month_data.short_name }} '{{ month_data.year_short }} +

+
+
S
+
M
+
T
+
W
+
T
+
F
+
S
+ + {# Sunday-first alignment: shift Monday=0 → Sunday=0 #} + {% set first_offset = (month_data.first_weekday + 1) % 7 %} + {% for i in range(first_offset) %} +
+ {% endfor %} + + {% for day_num in range(1, month_data.num_days + 1) %} + {% set date_str = '%04d-%02d-%02d' | format(month_data.year, month_data.month, day_num) %} + {% set is_today = date_str == today %} + {% set day_proj_ids = month_data.active_days.get(day_num, []) %} +
+ {{ day_num }} + {% if day_proj_ids %} + + {% for pid in day_proj_ids[:4] %} + {% set p = (calendar.projects | selectattr('id', 'equalto', pid) | first) %} + {% if p %} + + {% endif %} + {% endfor %} + {% if day_proj_ids | length > 4 %} + +{{ day_proj_ids | length - 4 }} + {% endif %} + + {% endif %} +
+ {% endfor %} +
+
+ {% endfor %} +
+ + +
+ + + + + + + {{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} – {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }} + + + + + + + + Recent + +
+ +{% else %} +
+ + + +

No deployments in this window.

+

Try the navigation buttons below to look at a different range.

+
+{% endif %} + +
{# /#dh-view-calendar #} + + + {# /#dh-view-gantt #} + + + {# /#dh-view-byunit #} + + + + + + +{% endblock %} diff --git a/templates/admin/pending_deployments.html b/templates/admin/pending_deployments.html new file mode 100644 index 0000000..2157f52 --- /dev/null +++ b/templates/admin/pending_deployments.html @@ -0,0 +1,462 @@ +{% extends "base.html" %} + +{% block title %}Pending Deployments - Seismo Fleet Manager{% endblock %} + +{% block content %} +
+ ← Back to Tools +

Pending Deployments

+

+ Captures from the field waiting to be classified. + Capture a new one → +

+
+ + +
+ + + +
+ +
+
Loading…
+
+ + + + + +{% endblock %} diff --git a/templates/admin_events.html b/templates/admin_events.html new file mode 100644 index 0000000..9f45799 --- /dev/null +++ b/templates/admin_events.html @@ -0,0 +1,359 @@ +{% extends "base.html" %} + +{% block title %}SFM Event DB Manager - Seismo Fleet Manager{% endblock %} + +{% block content %} +
+
+ ← Back to Developer Tools +

SFM Event DB Manager

+

Browse, flag, and delete triggered events from SFM's events table. Destructive actions also clean up on-disk waveform / sidecar / pickle / hdf5 files.

+
+ +
+ + +
+
+ ⚠️ +
+ Destructive operations. + Delete actions remove rows from SFM's events table AND delete associated waveform files on disk. Both are permanent — there is no undo. Use filters carefully, dry-run first, and verify the match count before confirming. +
+
+
+ + +
+

Filters

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + +
+ 0 selected + + + + + +
+ + + + +
+ + +
+
+ Apply filters above to load events. +
+
+
No query run yet.
+
+
+ + +{% endblock %} diff --git a/templates/base.html b/templates/base.html index d4b9ccf..e19acb3 100644 --- a/templates/base.html +++ b/templates/base.html @@ -210,7 +210,11 @@ - + diff --git a/templates/dashboard.html b/templates/dashboard.html index 8b8e22c..cf42e61 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -10,17 +10,75 @@ {% endif %} -
+

Dashboard

Fleet overview and recent activity

-
-

Last updated

-

--

+
+ +
+

Last updated

+

--

+
+ + + + +
+
+

Field Deploy

+

+ Capture an install while you're still on site. Project + location can be picked later at a desk. +

+
+ + +
+
+ 1 + Unit +
+
+
+ 2 + Photo +
+
+
+ 3 + Confirm +
+
+ + +
+ +
+
+ + + + + + + + + +
+ + +{% endblock %} diff --git a/templates/partials/projects/location_map.html b/templates/partials/projects/location_map.html new file mode 100644 index 0000000..d9c9fde --- /dev/null +++ b/templates/partials/projects/location_map.html @@ -0,0 +1,159 @@ + +
+
+

Location Map

+ +
+
+
+ + +
+ + diff --git a/templates/partials/projects/project_dashboard.html b/templates/partials/projects/project_dashboard.html index 5b859e2..c4ff551 100644 --- a/templates/partials/projects/project_dashboard.html +++ b/templates/partials/projects/project_dashboard.html @@ -78,128 +78,19 @@
- -
- - -
- - -
+ {# Location map — uses the reusable partial that fetches from + /api/projects/{p}/locations-json. Same render is reused on the + deeper Vibration tab so both surfaces stay in sync. #} + {% with project_id=project.id %} + {% include 'partials/projects/location_map.html' %} + {% endwith %} - +{% if upcoming_actions %} + +{% endif %} diff --git a/templates/projects/detail.html b/templates/projects/detail.html index 4f75550..ba971f3 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -101,22 +101,33 @@ -
-
-

Monitoring Locations

- +
+
+
+

Monitoring Locations

+ +
+
+
Loading locations...
+
-
-
Loading locations...
+ + {# Reusable location map — fetches from /locations-json + on its own. Hovering any of the location cards on the + left highlights the matching pin on this map. #} +
+ {% with project_id=project_id, location_type='vibration', map_height='450px' %} + {% include 'partials/projects/location_map.html' %} + {% endwith %}
diff --git a/templates/settings.html b/templates/settings.html index cb586a7..ba5532e 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -589,6 +589,20 @@
+ +
+
+
SFM Event DB Manager
+
+ Browse, flag, and delete events from SFM's events table across all units. Destructive — also cleans up on-disk waveform files. Use for cleaning bogus events from a misbehaving unit. +
+
+ + Open + +
+ {# Metadata Backfill + Project Tidy moved to Tools (they're operator workflows, not admin/dev surfaces). Find them at /tools. #} diff --git a/templates/tools.html b/templates/tools.html index 4b20006..d9649f6 100644 --- a/templates/tools.html +++ b/templates/tools.html @@ -14,6 +14,43 @@ + + +
+
+ + + +
+
+

Deployment History

+

+ 12-month calendar of every unit assignment across every project. Visual bars per project per day; click a day for the full active-deployments list. +

+
+
+
+ diff --git a/templates/unit_detail.html b/templates/unit_detail.html index beec92b..c2eb60f 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -379,6 +379,20 @@
+ + +
@@ -2652,7 +2666,13 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal ? 'FT' : ''; + const checked = _ueSelectedEventIds.has(ev.id) ? 'checked' : ''; return ` + + + ${ts} ${tran} ${vert} @@ -2668,6 +2688,11 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal + ${_ueSortableTh('Timestamp', 'timestamp')} ${_ueSortableTh('Tran', 'tran_ppv')} ${_ueSortableTh('Vert', 'vert_ppv')} @@ -2679,6 +2704,85 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal ${rows}
+ +
`; + _ueRefreshBulkButton(); +} + +// ===== Bulk false-trigger flagging ===== +// Selection is keyed by event ID and persists across table re-renders, so +// users can paginate / re-sort without losing their selection. +const _ueSelectedEventIds = new Set(); + +function _ueRefreshBulkButton() { + const n = _ueSelectedEventIds.size; + const lbl = document.getElementById('ue-bulk-selected'); + const flag = document.getElementById('ue-bulk-flag-ft'); + const clr = document.getElementById('ue-bulk-clear-ft'); + if (lbl) lbl.textContent = `${n} selected`; + if (flag) flag.disabled = (n === 0); + if (clr) clr.disabled = (n === 0); +} + +function onUnitEventRowCheck(input) { + const id = input.getAttribute('data-event-id'); + if (input.checked) { + _ueSelectedEventIds.add(id); + } else { + _ueSelectedEventIds.delete(id); + // If we just unchecked a row, the master "all" checkbox shouldn't stay checked. + const master = document.getElementById('ue-check-all'); + if (master) master.checked = false; + } + _ueRefreshBulkButton(); +} + +function toggleAllUnitEventRows(checked) { + document.querySelectorAll('.ue-row-check').forEach(cb => { + const id = cb.getAttribute('data-event-id'); + cb.checked = checked; + if (checked) _ueSelectedEventIds.add(id); + else _ueSelectedEventIds.delete(id); + }); + _ueRefreshBulkButton(); +} + +async function flagSelectedUnitEvents(value) { + // value = true → flag as false trigger + // value = false → clear false-trigger flag + if (_ueSelectedEventIds.size === 0) return; + const ids = Array.from(_ueSelectedEventIds); + const verb = value ? 'flag as false trigger' : 'clear false-trigger flag on'; + if (!confirm(`${verb} ${ids.length} event${ids.length === 1 ? '' : 's'}?`)) { + return; + } + + // SFM exposes single-row PATCH only. Fan out concurrently with a + // modest cap so we don't open hundreds of sockets at once. + const concurrency = 8; + let ok = 0, failed = 0; + let cursor = 0; + async function worker() { + while (cursor < ids.length) { + const i = cursor++; + const id = ids[i]; + try { + const resp = await fetch( + `/api/sfm/db/events/${encodeURIComponent(id)}/false_trigger?value=${value ? 'true' : 'false'}`, + { method: 'PATCH' } + ); + if (resp.ok) ok++; + else failed++; + } catch (_) { + failed++; + } + } + } + await Promise.all(Array.from({ length: concurrency }, worker)); + + if (failed) { + alert(`${ok} updated, ${failed} failed. Refreshing table.`); + } + _ueSelectedEventIds.clear(); + loadUnitEvents(); } // ===== Pair Device Modal Functions =====