Merge pull request 'merge v0.12.0' (#51) from dev into main

Reviewed-on: #51
This commit was merged in pull request #51.
This commit is contained in:
2026-05-17 19:44:56 -04:00
21 changed files with 3452 additions and 152 deletions
+73
View File
@@ -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 `<input capture="environment">`) → 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_<uuid8>.<ext>`. 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.
+1 -1
View File
@@ -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
+23 -1
View File
@@ -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"""
@@ -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.")
+55
View File
@@ -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)
+9
View File
@@ -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.
+99
View File
@@ -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,
})
+452
View File
@@ -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": "<uuid>",
"notes": "<optional>"
}
2. Create a new location under (existing or new) project:
{
"project_id": "<existing>" | null, # null means create new
"project_name": "<required if project_id is null>",
"project_type_id": "<existing project_type id, e.g. 'vibration_monitoring'>",
# required if creating new project
"location_name": "<required>",
"use_captured_coords": true | false, # default true — write the
# pending's coordinates onto
# the new location
"notes": "<optional>"
}
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
+387
View File
@@ -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
+711
View File
@@ -0,0 +1,711 @@
{% extends "base.html" %}
{% block title %}Deployment History - Seismo Fleet Manager{% endblock %}
{% block extra_head %}
<style>
.dh-calendar-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
@media (max-width: 1280px) { .dh-calendar-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 768px) { .dh-calendar-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 480px) { .dh-calendar-grid { grid-template-columns: 1fr; } }
.dh-month-card {
background: white;
border-radius: 0.5rem;
padding: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.dark .dh-month-card { background: rgb(30 41 59); }
.dh-day-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
font-size: 0.75rem;
}
.dh-day-header {
text-align: center;
font-weight: 600;
color: #6b7280;
padding: 0.25rem 0;
}
.dark .dh-day-header { color: #9ca3af; }
.dh-day-cell {
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border-radius: 0.25rem;
position: relative;
transition: all 0.15s ease;
font-size: 0.7rem;
padding-top: 2px;
background-color: #f3f4f6;
color: #374151;
cursor: pointer;
}
.dh-day-cell:hover {
transform: scale(1.1);
z-index: 10;
}
.dh-day-cell.empty {
background: transparent;
cursor: default;
}
.dh-day-cell.empty:hover { transform: none; }
.dh-day-cell.today {
ring-color: #f48b1c;
font-weight: 700;
color: #b84a12;
}
.dark .dh-day-cell {
background-color: rgba(55, 65, 81, 0.5);
color: #d1d5db;
}
.dh-day-cell .dh-day-num {
text-align: center;
flex-shrink: 0;
}
.dh-day-cell .dh-bars {
display: flex;
flex-direction: column;
gap: 1px;
margin: 2px 2px 0 2px;
}
.dh-bar {
height: 3px;
border-radius: 1px;
}
</style>
{% endblock %}
{% block content %}
<div class="mb-6 flex items-center justify-between flex-wrap gap-3">
<div>
<a href="/tools" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Tools</a>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">Deployment History</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">
Where every unit has been — actual assignment windows, color-coded by project.
For future / planned deployments use the <a href="/fleet-calendar" class="text-seismo-orange hover:text-seismo-burgundy">Job Planner</a>.
</p>
</div>
</div>
<!-- View tabs: Calendar | Gantt (by project) | By Unit (gantt by unit) -->
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 mb-6 w-fit">
<button id="dh-tab-calendar" onclick="switchDhView('calendar')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
Calendar
</button>
<button id="dh-tab-gantt" onclick="switchDhView('gantt')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
Gantt by Project
</button>
<button id="dh-tab-byunit" onclick="switchDhView('byunit')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
Gantt by Unit
</button>
</div>
<!-- KPI strip -->
<div class="flex flex-wrap items-center gap-4 mb-6">
<div class="flex items-center gap-3 text-sm bg-white dark:bg-slate-800 rounded-lg px-4 py-2 shadow">
<span class="text-gray-500 dark:text-gray-400">{{ calendar.projects | length }} project{{ '' if calendar.projects | length == 1 else 's' }}</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="text-gray-500 dark:text-gray-400">{{ calendar.total_active_units }} unique units</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="text-gray-500 dark:text-gray-400">{{ calendar.total_assignments }} assignment{{ '' if calendar.total_assignments == 1 else 's' }} in window</span>
</div>
{% if calendar.projects %}
<details class="bg-white dark:bg-slate-800 rounded-lg shadow px-4 py-2">
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300 select-none">Project legend</summary>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
{% for p in calendar.projects %}
<a href="/projects/{{ p.id }}"
class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 hover:text-seismo-orange">
<span class="w-3 h-1.5 rounded-full flex-shrink-0" style="background-color: {{ p.color }};"></span>
{{ p.name }}
<span class="text-xs text-gray-400 dark:text-gray-500">·</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ p.assignment_count }}</span>
</a>
{% endfor %}
</div>
</details>
{% endif %}
</div>
<!-- ─── Calendar view ─── -->
<div id="dh-view-calendar">
<!-- Calendar grid -->
{% if calendar.projects %}
<div class="dh-calendar-grid mb-6">
{% for month_data in calendar.months %}
<div class="dh-month-card">
<h3 class="font-semibold text-gray-900 dark:text-white mb-2 text-center">
{{ month_data.short_name }} '{{ month_data.year_short }}
</h3>
<div class="dh-day-grid">
<div class="dh-day-header">S</div>
<div class="dh-day-header">M</div>
<div class="dh-day-header">T</div>
<div class="dh-day-header">W</div>
<div class="dh-day-header">T</div>
<div class="dh-day-header">F</div>
<div class="dh-day-header">S</div>
{# Sunday-first alignment: shift Monday=0 → Sunday=0 #}
{% set first_offset = (month_data.first_weekday + 1) % 7 %}
{% for i in range(first_offset) %}
<div class="dh-day-cell empty"></div>
{% 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, []) %}
<div class="dh-day-cell{% if is_today %} today ring-2 ring-seismo-orange{% endif %}"
onclick="openDhDay('{{ date_str }}')"
title="{{ date_str }} — {{ day_proj_ids | length }} project{{ '' if day_proj_ids | length == 1 else 's' }}">
<span class="dh-day-num">{{ day_num }}</span>
{% if day_proj_ids %}
<span class="dh-bars">
{% for pid in day_proj_ids[:4] %}
{% set p = (calendar.projects | selectattr('id', 'equalto', pid) | first) %}
{% if p %}
<span class="dh-bar" style="background-color: {{ p.color }};"
title="{{ p.name }}"></span>
{% endif %}
{% endfor %}
{% if day_proj_ids | length > 4 %}
<span class="text-[8px] text-gray-500 leading-none">+{{ day_proj_ids | length - 4 }}</span>
{% endif %}
</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<!-- Month navigation -->
<div class="flex items-center justify-center gap-3 mb-8">
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Previous month">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
</span>
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Next month">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
<a href="/tools/deployment-history"
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
Recent
</a>
</div>
{% else %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-sm">No deployments in this window.</p>
<p class="text-xs mt-1">Try the navigation buttons below to look at a different range.</p>
</div>
{% endif %}
</div> {# /#dh-view-calendar #}
<!-- ─── Gantt view ─── -->
<div id="dh-view-gantt" class="hidden">
{% if calendar.projects %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 overflow-x-auto">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
One row per project. Each bar is one assignment window (unit at a location).
Hover for details, click to open the project.
Active deployments end at "now" with a small white tip.
</p>
<svg id="dh-gantt-svg" preserveAspectRatio="none"
style="width: 100%; min-width: 800px;"></svg>
</div>
<!-- Same nav as calendar view, repeated here so the Gantt view has its own. -->
<div class="flex items-center justify-center gap-3 mb-8">
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}#gantt"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Previous month">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
</span>
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}#gantt"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Next month">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
<a href="/tools/deployment-history#gantt"
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
Recent
</a>
</div>
{% else %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
<p class="text-sm">No deployments in this window.</p>
</div>
{% endif %}
</div> {# /#dh-view-gantt #}
<!-- ─── Gantt-by-Unit view ─── -->
<div id="dh-view-byunit" class="hidden">
{% if calendar.units %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 overflow-x-auto">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
One row per seismograph that had ≥1 assignment in this window.
Bars are colored by <strong>project</strong>, so an operator can read where each unit travelled
across jobs. Active deployments end at "now" with a small white tip.
Click a unit ID to open its detail page; click a bar to open the bar's project.
</p>
<svg id="dh-byunit-svg" preserveAspectRatio="none"
style="width: 100%; min-width: 800px;"></svg>
</div>
<div class="flex items-center justify-center gap-3 mb-8">
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}#byunit"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Previous month">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
</span>
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}#byunit"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Next month">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
<a href="/tools/deployment-history#byunit"
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
Recent
</a>
</div>
{% else %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
<p class="text-sm">No unit deployments in this window.</p>
</div>
{% endif %}
</div> {# /#dh-view-byunit #}
<!-- Day-detail side panel -->
<div id="dh-day-panel-backdrop"
class="fixed inset-0 bg-black/30 z-40 hidden transition-opacity"
onclick="closeDhDayPanel()"></div>
<div id="dh-day-panel"
class="fixed top-0 right-0 h-screen w-full max-w-md bg-white dark:bg-slate-800 shadow-2xl z-50 hidden transform translate-x-full transition-transform duration-300 overflow-y-auto">
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white" id="dh-day-panel-title">Deployments on…</h2>
<p class="text-xs text-gray-500 dark:text-gray-400" id="dh-day-panel-subtitle"></p>
</div>
<button onclick="closeDhDayPanel()" class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div id="dh-day-panel-body" class="p-5 space-y-3">
<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange mx-auto mb-2"></div>
Loading…
</div>
</div>
</div>
<script>
function _dhEsc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function openDhDay(dateStr) {
const panel = document.getElementById('dh-day-panel');
const backdrop = document.getElementById('dh-day-panel-backdrop');
const title = document.getElementById('dh-day-panel-title');
const sub = document.getElementById('dh-day-panel-subtitle');
const body = document.getElementById('dh-day-panel-body');
title.textContent = 'Deployments on ' + dateStr;
sub.textContent = '';
body.innerHTML = `<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange mx-auto mb-2"></div>
Loading…
</div>`;
panel.classList.remove('hidden');
backdrop.classList.remove('hidden');
requestAnimationFrame(() => panel.classList.remove('translate-x-full'));
try {
const r = await fetch(`/api/admin/deployment-history/day?target_date=${encodeURIComponent(dateStr)}`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
renderDhDayPanel(d, dateStr);
} catch (e) {
body.innerHTML = `<p class="text-sm text-red-500">Failed to load: ${_dhEsc(e.message)}</p>`;
}
}
function renderDhDayPanel(d, dateStr) {
const sub = document.getElementById('dh-day-panel-subtitle');
const body = document.getElementById('dh-day-panel-body');
sub.textContent = `${d.count} active deployment${d.count === 1 ? '' : 's'}`;
if (d.count === 0) {
body.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No active deployments on this date.</p>';
return;
}
// Group by project for cleaner display.
const byProj = {};
for (const dep of d.deployments) {
const key = dep.project_id || '_none';
if (!byProj[key]) byProj[key] = { name: dep.project_name, color: dep.project_color, items: [] };
byProj[key].items.push(dep);
}
let html = '';
for (const pid of Object.keys(byProj)) {
const proj = byProj[pid];
html += `<div class="space-y-1">
<div class="flex items-center justify-between">
<a href="/projects/${_dhEsc(pid)}" class="flex items-center gap-2 font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
<span class="w-3 h-3 rounded-full" style="background-color: ${proj.color};"></span>
${_dhEsc(proj.name)}
</a>
<span class="text-xs text-gray-500 dark:text-gray-400">${proj.items.length} unit${proj.items.length === 1 ? '' : 's'}</span>
</div>
<div class="space-y-1 ml-5">`;
for (const dep of proj.items) {
const activeBadge = dep.is_active
? '<span class="text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
: '';
const sourceTag = dep.source === 'metadata_backfill'
? '<span class="text-[10px] text-blue-600 dark:text-blue-400 italic">auto-backfilled</span>'
: '';
const windowEnd = dep.is_active ? 'present' : (dep.assigned_until || '').slice(0, 10);
const windowStart = (dep.assigned_at || '').slice(0, 10);
html += `<div class="flex items-center justify-between gap-2 py-1 text-sm">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<a href="/unit/${_dhEsc(dep.unit_id)}" class="font-mono font-medium text-seismo-orange hover:text-seismo-navy">${_dhEsc(dep.unit_id)}</a>
${activeBadge}
${sourceTag}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
📍 <a href="/projects/${_dhEsc(pid)}/nrl/${_dhEsc(dep.location_id)}" class="hover:text-seismo-orange">${_dhEsc(dep.location_name)}</a>
</div>
<div class="text-[11px] text-gray-400 dark:text-gray-500 font-mono">${windowStart} → ${windowEnd}</div>
</div>
</div>`;
}
html += `</div></div>`;
}
body.innerHTML = html;
}
function closeDhDayPanel() {
const panel = document.getElementById('dh-day-panel');
const backdrop = document.getElementById('dh-day-panel-backdrop');
panel.classList.add('translate-x-full');
setTimeout(() => {
panel.classList.add('hidden');
backdrop.classList.add('hidden');
}, 300);
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDhDayPanel();
});
// ── Tab switcher ────────────────────────────────────────────────────
const _DH_TABS = {
calendar: { view: 'dh-view-calendar', btn: 'dh-tab-calendar', hash: '', render: null },
gantt: { view: 'dh-view-gantt', btn: 'dh-tab-gantt', hash: '#gantt', render: () => renderDhGantt() },
byunit: { view: 'dh-view-byunit', btn: 'dh-tab-byunit', hash: '#byunit', render: () => renderDhByUnit() },
};
function switchDhView(which) {
const target = _DH_TABS[which] || _DH_TABS.calendar;
const activeCls = ['bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow'];
const dormantCls = ['text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white'];
// Hide all views, dormant-style all tabs, then activate the chosen one.
for (const key of Object.keys(_DH_TABS)) {
const t = _DH_TABS[key];
const v = document.getElementById(t.view);
const b = document.getElementById(t.btn);
if (v) v.classList.add('hidden');
if (b) { b.classList.remove(...activeCls); b.classList.add(...dormantCls); }
}
const v = document.getElementById(target.view);
const b = document.getElementById(target.btn);
if (v) v.classList.remove('hidden');
if (b) { b.classList.add(...activeCls); b.classList.remove(...dormantCls); }
// URL hash sync.
const wanted = target.hash;
const cur = window.location.hash || '';
if (cur !== wanted) {
history.replaceState(null, '', window.location.pathname + window.location.search + wanted);
}
if (target.render) target.render();
}
// Land on the requested view if the URL hash matches.
const _initialHash = window.location.hash;
if (_initialHash === '#gantt' || _initialHash === '#byunit') {
document.addEventListener('DOMContentLoaded', () => switchDhView(_initialHash.slice(1)));
}
// ── Gantt renderer ──────────────────────────────────────────────────
// Server-rendered data: per-project list with each project's bars
// (assignment windows clipped to the visible window).
const _dhProjects = {{ calendar.projects | tojson }};
const _dhWindowStart = {{ calendar.window.first_date | tojson }};
const _dhWindowEnd = {{ calendar.window.last_date | tojson }};
let _ganttRendered = false;
function renderDhGantt() {
if (_ganttRendered) return; // build once; data doesn't change after page load
_ganttRendered = true;
const svg = document.getElementById('dh-gantt-svg');
if (!svg) return;
if (!_dhProjects || _dhProjects.length === 0) {
svg.innerHTML = '';
return;
}
const isDark = document.documentElement.classList.contains('dark');
const labelColor = isDark ? '#9ca3af' : '#6b7280';
const gridColor = isDark ? '#374151' : '#e5e7eb';
const rowAltBg = isDark ? '#1e293b' : '#f9fafb';
const todayColor = '#f48b1c';
// Geometry. The Gantt SVG has a left "labels" gutter and a right
// time-axis area. Width is fluid (matches container); height grows
// with the number of project rows.
const containerW = svg.parentElement.clientWidth || 1000;
const width = Math.max(containerW, 800);
const labelW = 220;
const padTop = 36; // room for month labels above
const padBottom = 16;
const rowH = 36;
const barH = 18;
const height = padTop + padBottom + _dhProjects.length * rowH;
const usableW = width - labelW - 8;
const tStart = new Date(_dhWindowStart + 'T00:00:00Z').getTime();
const tEnd = new Date(_dhWindowEnd + 'T23:59:59Z').getTime();
const tRange = tEnd - tStart;
const xFor = (d) => {
const ms = (typeof d === 'string') ? new Date(d + 'T00:00:00Z').getTime() : d;
return labelW + Math.max(0, Math.min(usableW, (ms - tStart) / tRange * usableW));
};
// Month gridlines + month labels along the top.
// Walk every 1st-of-month inside the window.
const monthsParts = [];
const cursor = new Date(_dhWindowStart + 'T00:00:00Z');
cursor.setUTCDate(1);
while (cursor.getTime() <= tEnd) {
const x = xFor(cursor);
const label = cursor.toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
monthsParts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
monthsParts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${label}</text>`);
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
// Today marker.
const now = Date.now();
let todayMarker = '';
if (now >= tStart && now <= tEnd) {
const x = xFor(now);
todayMarker = `<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.85"/>
<text x="${x + 3}" y="${padTop - 20}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`;
}
// Build rows.
const rowParts = [];
_dhProjects.forEach((proj, idx) => {
const y = padTop + idx * rowH;
// Alternating row background.
if (idx % 2 === 1) {
rowParts.push(`<rect x="0" y="${y}" width="${width}" height="${rowH}" fill="${rowAltBg}"/>`);
}
// Project label (clipped to gutter, clickable).
const labelText = proj.name.length > 28 ? proj.name.slice(0, 26) + '…' : proj.name;
rowParts.push(`<a href="/projects/${_dhEsc(proj.id)}" target="_top">
<rect x="0" y="${y}" width="${labelW}" height="${rowH}" fill="transparent"/>
<circle cx="14" cy="${y + rowH / 2}" r="4" fill="${proj.color}"/>
<text x="24" y="${y + rowH / 2 + 4}" font-size="12" fill="${labelColor}"
font-family="system-ui,sans-serif"
title="${_dhEsc(proj.name)}">
${_dhEsc(labelText)}
</text>
</a>`);
// Bars for each assignment in this project.
for (const bar of (proj.bars || [])) {
const x1 = xFor(bar.start);
const x2 = xFor(bar.end);
const barW = Math.max(x2 - x1, 2);
const by = y + (rowH - barH) / 2;
const tip = `${bar.unit_id} @ ${bar.location_name}\n${bar.start} → ${bar.is_active ? 'present' : bar.end}`;
rowParts.push(`<g style="cursor: pointer;">
<title>${_dhEsc(tip)}</title>
<rect x="${x1}" y="${by}" width="${barW}" height="${barH}"
rx="3"
fill="${proj.color}" opacity="${bar.is_active ? 1.0 : 0.75}"
${bar.source === 'metadata_backfill' ? 'stroke="#3b82f6" stroke-width="1.5"' : ''}/>
${bar.is_active ? `<rect x="${x2 - 3}" y="${by}" width="3" height="${barH}" fill="#ffffff" opacity="0.8"/>` : ''}
</g>`);
}
});
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.setAttribute('height', height);
svg.innerHTML = monthsParts.join('') + rowParts.join('') + todayMarker;
}
// ── Gantt-by-Unit renderer ──────────────────────────────────────────
// Inverts the Gantt: rows = seismographs, bars = assignment windows
// colored by project (so an operator can read where each unit went
// across jobs). Shares the time-axis geometry with renderDhGantt().
const _dhUnits = {{ calendar.units | tojson }};
let _byunitRendered = false;
function renderDhByUnit() {
if (_byunitRendered) return;
_byunitRendered = true;
const svg = document.getElementById('dh-byunit-svg');
if (!svg) return;
if (!_dhUnits || _dhUnits.length === 0) {
svg.innerHTML = '';
return;
}
const isDark = document.documentElement.classList.contains('dark');
const labelColor = isDark ? '#9ca3af' : '#6b7280';
const labelStrong = isDark ? '#e5e7eb' : '#111827';
const gridColor = isDark ? '#374151' : '#e5e7eb';
const rowAltBg = isDark ? '#1e293b' : '#f9fafb';
const todayColor = '#f48b1c';
const containerW = svg.parentElement.clientWidth || 1000;
const width = Math.max(containerW, 800);
const labelW = 160;
const padTop = 36;
const padBottom = 16;
const rowH = 32;
const barH = 16;
const height = padTop + padBottom + _dhUnits.length * rowH;
const usableW = width - labelW - 8;
const tStart = new Date(_dhWindowStart + 'T00:00:00Z').getTime();
const tEnd = new Date(_dhWindowEnd + 'T23:59:59Z').getTime();
const tRange = tEnd - tStart;
const xFor = (d) => {
const ms = (typeof d === 'string') ? new Date(d + 'T00:00:00Z').getTime() : d;
return labelW + Math.max(0, Math.min(usableW, (ms - tStart) / tRange * usableW));
};
// Month gridlines + labels (same as renderDhGantt).
const monthsParts = [];
const cursor = new Date(_dhWindowStart + 'T00:00:00Z');
cursor.setUTCDate(1);
while (cursor.getTime() <= tEnd) {
const x = xFor(cursor);
const label = cursor.toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
monthsParts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
monthsParts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${label}</text>`);
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
const now = Date.now();
let todayMarker = '';
if (now >= tStart && now <= tEnd) {
const x = xFor(now);
todayMarker = `<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.85"/>
<text x="${x + 3}" y="${padTop - 20}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`;
}
const rowParts = [];
_dhUnits.forEach((unit, idx) => {
const y = padTop + idx * rowH;
if (idx % 2 === 1) {
rowParts.push(`<rect x="0" y="${y}" width="${width}" height="${rowH}" fill="${rowAltBg}"/>`);
}
// Unit label: opens /unit/{id} when clicked. Active-now dot.
const activeDot = unit.any_active
? `<circle cx="12" cy="${y + rowH / 2}" r="3.5" fill="#22c55e"/>`
: `<circle cx="12" cy="${y + rowH / 2}" r="3.5" fill="${labelColor}" opacity="0.4"/>`;
rowParts.push(`<a href="/unit/${_dhEsc(unit.id)}" target="_top">
<rect x="0" y="${y}" width="${labelW}" height="${rowH}" fill="transparent"/>
${activeDot}
<text x="22" y="${y + rowH / 2 + 4}" font-size="12" fill="${labelStrong}"
font-family="system-ui,sans-serif" font-weight="500">
${_dhEsc(unit.id)}
</text>
<text x="${labelW - 8}" y="${y + rowH / 2 + 4}" font-size="10" fill="${labelColor}"
text-anchor="end" font-family="system-ui,sans-serif">
${unit.assignment_count}
</text>
</a>`);
// Bars — colored by the bar's project (not the row).
for (const bar of (unit.bars || [])) {
const x1 = xFor(bar.start);
const x2 = xFor(bar.end);
const barW = Math.max(x2 - x1, 2);
const by = y + (rowH - barH) / 2;
const tip = `${bar.project_name}\n${bar.location_name}\n${bar.start} → ${bar.is_active ? 'present' : bar.end}`;
rowParts.push(`<a href="/projects/${_dhEsc(bar.project_id)}" target="_top">
<g style="cursor: pointer;">
<title>${_dhEsc(tip)}</title>
<rect x="${x1}" y="${by}" width="${barW}" height="${barH}"
rx="3"
fill="${bar.project_color}" opacity="${bar.is_active ? 1.0 : 0.75}"
${bar.source === 'metadata_backfill' ? 'stroke="#3b82f6" stroke-width="1.5"' : ''}/>
${bar.is_active ? `<rect x="${x2 - 3}" y="${by}" width="3" height="${barH}" fill="#ffffff" opacity="0.8"/>` : ''}
</g>
</a>`);
}
});
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.setAttribute('height', height);
svg.innerHTML = monthsParts.join('') + rowParts.join('') + todayMarker;
}
</script>
{% endblock %}
+462
View File
@@ -0,0 +1,462 @@
{% extends "base.html" %}
{% block title %}Pending Deployments - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="mb-6">
<a href="/tools" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Tools</a>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">Pending Deployments</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">
Captures from the field waiting to be classified.
<a href="/deploy" class="text-seismo-orange hover:text-seismo-navy">Capture a new one →</a>
</p>
</div>
<!-- Filter pills -->
<div class="flex gap-2 mb-6">
<button onclick="switchPdStatus('awaiting')" id="pd-tab-awaiting"
class="px-4 py-2 rounded-lg text-sm font-medium bg-seismo-orange text-white">
Awaiting <span id="pd-count-awaiting" class="ml-1 text-xs opacity-80"></span>
</button>
<button onclick="switchPdStatus('assigned')" id="pd-tab-assigned"
class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
Assigned
</button>
<button onclick="switchPdStatus('cancelled')" id="pd-tab-cancelled"
class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
Cancelled
</button>
</div>
<div id="pd-list" class="space-y-4">
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading…</div>
</div>
<!-- Classify modal -->
<div id="classify-modal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto" style="min-height: 480px;">
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Classify pending deployment</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
<span id="classify-unit-label" class="font-mono text-seismo-orange"></span>
captured at
<span id="classify-captured-at"></span>
</p>
</div>
<button onclick="closeClassifyModal()" class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="p-5 space-y-5">
<!-- Mode toggle -->
<div class="flex gap-2">
<button onclick="setClassifyMode('existing')" id="mode-existing"
class="flex-1 px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white">
Assign to existing location
</button>
<button onclick="setClassifyMode('new')" id="mode-new"
class="flex-1 px-3 py-2 text-sm rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
Create new location
</button>
</div>
<!-- Existing mode: project + location pickers -->
<div id="classify-existing-pane" class="space-y-3">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
<select id="existing-project-select" onchange="onExistingProjectChange()"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<option value="">Loading projects…</option>
</select>
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Location</label>
<select id="existing-location-select"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<option value="">Pick a project first</option>
</select>
</div>
</div>
<!-- New mode: project (existing/new) + location name -->
<div id="classify-new-pane" class="hidden space-y-3">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
<div class="flex gap-2 mt-1">
<select id="new-project-select" onchange="onNewProjectMode()"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<option value="">— Create new project —</option>
</select>
</div>
</div>
<div id="new-project-name-wrap" class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">New project name</label>
<input id="new-project-name" type="text" placeholder="e.g. Carnegie Museum HVAC"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<p class="text-xs text-gray-500 dark:text-gray-400">Project type will be Vibration Monitoring.</p>
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Location name</label>
<input id="new-location-name" type="text" placeholder="e.g. NE corner, near loading dock"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" id="use-captured-coords" checked
class="rounded border-gray-300 text-seismo-orange focus:ring-seismo-orange">
Use the photo's GPS coords <span id="captured-coords-hint" class="text-xs text-gray-500 dark:text-gray-400"></span>
</label>
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Assignment notes (optional)</label>
<textarea id="classify-notes" rows="2"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm"></textarea>
</div>
<div id="classify-error" class="hidden text-sm text-red-600"></div>
</div>
<div class="sticky bottom-0 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700 px-5 py-3 flex justify-end gap-2">
<button onclick="closeClassifyModal()"
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button id="classify-submit" onclick="submitClassify()"
class="px-4 py-2 text-sm bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
Classify
</button>
</div>
</div>
</div>
<script>
let _pdState = {
currentStatus: 'awaiting',
rows: [],
classifyingId: null,
classifyingPd: null,
classifyMode: 'existing', // 'existing' | 'new'
projectsCache: null,
};
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _fmtDateTime(iso) {
if (!iso) return '—';
return iso.replace('T', ' ').slice(0, 16);
}
async function loadPdList() {
const list = document.getElementById('pd-list');
list.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading…</div>';
try {
const r = await fetch(`/api/deployments/pending?status=${encodeURIComponent(_pdState.currentStatus)}`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
_pdState.rows = data.pending_deployments || [];
renderPdList();
// Refresh awaiting count badge.
if (_pdState.currentStatus === 'awaiting') {
document.getElementById('pd-count-awaiting').textContent = data.count > 0 ? `(${data.count})` : '';
}
} catch (e) {
list.innerHTML = `<div class="text-center py-8 text-red-500 text-sm">Load failed: ${_esc(e.message)}</div>`;
}
}
function renderPdList() {
const list = document.getElementById('pd-list');
if (_pdState.rows.length === 0) {
const blurb = {
awaiting: 'No captures awaiting classification.',
assigned: 'No assigned captures yet.',
cancelled: 'No cancelled captures.',
}[_pdState.currentStatus] || '';
list.innerHTML = `<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">${blurb}</div>`;
return;
}
list.innerHTML = _pdState.rows.map(pd => _renderPdCard(pd)).join('');
}
function _renderPdCard(pd) {
const photoUrl = pd.photo_url || '';
const coords = pd.coordinates
? `<span class="font-mono text-xs">${_esc(pd.coordinates)}</span>`
: '<span class="text-xs italic text-gray-400">no GPS in photo</span>';
const noteHtml = pd.operator_note
? `<p class="text-xs text-gray-600 dark:text-gray-300 mt-1 italic">"${_esc(pd.operator_note)}"</p>`
: '';
let footerActions = '';
if (pd.status === 'awaiting') {
footerActions = `<div class="flex gap-2 mt-3">
<button onclick="openClassifyModal('${_esc(pd.id)}')"
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm rounded-lg font-medium">
Classify
</button>
<button onclick="cancelPending('${_esc(pd.id)}')"
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm rounded-lg">
Cancel
</button>
</div>`;
} else if (pd.status === 'assigned') {
footerActions = `<div class="mt-3 text-xs text-green-700 dark:text-green-400">
Promoted ${_fmtDateTime(pd.promoted_at)} → assignment <span class="font-mono">${_esc((pd.resulting_assignment_id || '').slice(0, 8))}…</span>
</div>`;
} else if (pd.status === 'cancelled') {
footerActions = `<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
Cancelled ${_fmtDateTime(pd.cancelled_at)}${pd.cancelled_reason ? ` — ${_esc(pd.cancelled_reason)}` : ''}
</div>`;
}
return `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex gap-4">
<div class="shrink-0 w-32 h-32 bg-gray-100 dark:bg-slate-900 rounded-lg overflow-hidden">
${photoUrl
? `<img src="${_esc(photoUrl)}" class="w-full h-full object-cover" alt="install">`
: `<div class="w-full h-full flex items-center justify-center text-gray-400 text-xs">(no photo)</div>`}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<a href="/unit/${_esc(pd.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">
${_esc(pd.unit_id)}
</a>
<span class="text-xs text-gray-500 dark:text-gray-400">captured ${_fmtDateTime(pd.captured_at)}</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">${coords}</div>
${noteHtml}
${footerActions}
</div>
</div>`;
}
function switchPdStatus(status) {
_pdState.currentStatus = status;
const tabs = { awaiting: 'pd-tab-awaiting', assigned: 'pd-tab-assigned', cancelled: 'pd-tab-cancelled' };
Object.entries(tabs).forEach(([k, id]) => {
const btn = document.getElementById(id);
if (k === status) {
btn.classList.add('bg-seismo-orange', 'text-white');
btn.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300', 'hover:bg-gray-200', 'dark:hover:bg-gray-600');
} else {
btn.classList.remove('bg-seismo-orange', 'text-white');
btn.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300', 'hover:bg-gray-200', 'dark:hover:bg-gray-600');
}
});
loadPdList();
}
// ── Classify modal ──────────────────────────────────────────────────
async function openClassifyModal(pendingId) {
const pd = _pdState.rows.find(r => r.id === pendingId);
if (!pd) return;
_pdState.classifyingId = pendingId;
_pdState.classifyingPd = pd;
document.getElementById('classify-unit-label').textContent = pd.unit_id;
document.getElementById('classify-captured-at').textContent = _fmtDateTime(pd.captured_at);
document.getElementById('classify-notes').value = '';
document.getElementById('classify-error').classList.add('hidden');
document.getElementById('new-project-name').value = '';
document.getElementById('new-location-name').value = '';
// Coords hint for "use captured coords" checkbox.
const hint = document.getElementById('captured-coords-hint');
if (pd.coordinates) {
hint.textContent = `(${pd.coordinates})`;
document.getElementById('use-captured-coords').checked = true;
document.getElementById('use-captured-coords').disabled = false;
} else {
hint.textContent = '(no GPS in photo — uncheck unless you want a placeholder)';
document.getElementById('use-captured-coords').checked = false;
document.getElementById('use-captured-coords').disabled = true;
}
setClassifyMode('existing');
document.getElementById('classify-modal').classList.remove('hidden');
// Load projects if not cached.
if (!_pdState.projectsCache) {
await _loadProjects();
}
_populateProjectSelects();
}
function closeClassifyModal() {
document.getElementById('classify-modal').classList.add('hidden');
}
async function _loadProjects() {
try {
const r = await fetch('/api/projects/list');
const data = r.ok ? await r.json() : { projects: [] };
// Endpoint shape varies; tolerate either { projects: [...] } or a flat array.
_pdState.projectsCache = Array.isArray(data) ? data : (data.projects || []);
} catch (e) {
_pdState.projectsCache = [];
}
}
function _populateProjectSelects() {
// Sort active projects first, then alphabetical.
const projs = (_pdState.projectsCache || []).slice().sort((a, b) => {
if ((a.status || '') !== (b.status || '')) {
if (a.status === 'active') return -1;
if (b.status === 'active') return 1;
}
return (a.name || '').localeCompare(b.name || '');
});
const existingSel = document.getElementById('existing-project-select');
existingSel.innerHTML = '<option value="">— Pick a project —</option>' + projs.map(p =>
`<option value="${_esc(p.id)}">${_esc(p.name)}${p.status && p.status !== 'active' ? ` (${p.status})` : ''}</option>`
).join('');
const newSel = document.getElementById('new-project-select');
newSel.innerHTML = '<option value="">— Create new project —</option>' + projs.map(p =>
`<option value="${_esc(p.id)}">${_esc(p.name)}</option>`
).join('');
}
async function onExistingProjectChange() {
const projectId = document.getElementById('existing-project-select').value;
const locSel = document.getElementById('existing-location-select');
if (!projectId) {
locSel.innerHTML = '<option value="">Pick a project first</option>';
return;
}
locSel.innerHTML = '<option value="">Loading…</option>';
try {
const r = await fetch(`/api/projects/${projectId}/locations-json?location_type=vibration`);
const locs = await r.json();
if (!Array.isArray(locs) || locs.length === 0) {
locSel.innerHTML = '<option value="">(no locations — use Create new location instead)</option>';
return;
}
locSel.innerHTML = '<option value="">— Pick a location —</option>' + locs.map(l =>
`<option value="${_esc(l.id)}">${_esc(l.name)}</option>`
).join('');
} catch (e) {
locSel.innerHTML = `<option value="">Load failed: ${_esc(e.message)}</option>`;
}
}
function setClassifyMode(mode) {
_pdState.classifyMode = mode;
const ex = document.getElementById('mode-existing');
const nw = document.getElementById('mode-new');
document.getElementById('classify-existing-pane').classList.toggle('hidden', mode !== 'existing');
document.getElementById('classify-new-pane').classList.toggle('hidden', mode !== 'new');
const activeCls = ['bg-seismo-orange', 'text-white'];
const dormantCls = ['bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300', 'hover:bg-gray-200', 'dark:hover:bg-gray-600'];
if (mode === 'existing') {
ex.classList.add(...activeCls); ex.classList.remove(...dormantCls);
nw.classList.remove(...activeCls); nw.classList.add(...dormantCls);
} else {
nw.classList.add(...activeCls); nw.classList.remove(...dormantCls);
ex.classList.remove(...activeCls); ex.classList.add(...dormantCls);
}
}
function onNewProjectMode() {
const sel = document.getElementById('new-project-select');
const wrap = document.getElementById('new-project-name-wrap');
wrap.classList.toggle('hidden', !!sel.value);
}
async function submitClassify() {
const btn = document.getElementById('classify-submit');
const errEl = document.getElementById('classify-error');
errEl.classList.add('hidden');
btn.disabled = true; btn.textContent = 'Classifying…';
const body = { notes: document.getElementById('classify-notes').value.trim() };
if (_pdState.classifyMode === 'existing') {
const locId = document.getElementById('existing-location-select').value;
if (!locId) {
errEl.textContent = 'Pick a location.';
errEl.classList.remove('hidden');
btn.disabled = false; btn.textContent = 'Classify';
return;
}
body.location_id = locId;
} else {
const existingProj = document.getElementById('new-project-select').value;
const newName = document.getElementById('new-project-name').value.trim();
const locName = document.getElementById('new-location-name').value.trim();
if (!locName) {
errEl.textContent = 'Location name required.';
errEl.classList.remove('hidden');
btn.disabled = false; btn.textContent = 'Classify';
return;
}
if (existingProj) {
body.project_id = existingProj;
} else {
if (!newName) {
errEl.textContent = 'Project name (or pick an existing project) required.';
errEl.classList.remove('hidden');
btn.disabled = false; btn.textContent = 'Classify';
return;
}
body.project_name = newName;
body.project_type_id = 'vibration_monitoring';
}
body.location_name = locName;
body.use_captured_coords = document.getElementById('use-captured-coords').checked;
}
try {
const r = await fetch(`/api/deployments/pending/${_pdState.classifyingId}/promote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
closeClassifyModal();
loadPdList();
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
btn.disabled = false; btn.textContent = 'Classify';
}
}
async function cancelPending(pendingId) {
const reason = prompt('Cancel this capture?\n\nOptional reason:');
if (reason === null) return; // user hit Cancel on the prompt
try {
const r = await fetch(`/api/deployments/pending/${pendingId}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
});
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
loadPdList();
} catch (e) {
alert('Cancel failed: ' + e.message);
}
}
// Kick off the initial load.
loadPdList();
// Refresh awaiting count every 30s for the badge.
setInterval(() => {
if (_pdState.currentStatus === 'awaiting') loadPdList();
}, 30000);
</script>
{% endblock %}
+359
View File
@@ -0,0 +1,359 @@
{% extends "base.html" %}
{% block title %}SFM Event DB Manager - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="mb-6 flex items-center justify-between">
<div>
<a href="/settings#developer" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Developer Tools</a>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">SFM Event DB Manager</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Browse, flag, and delete triggered events from SFM's events table. Destructive actions also clean up on-disk waveform / sidecar / pickle / hdf5 files.</p>
</div>
<button onclick="loadEvents()"
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg">
↻ Refresh
</button>
</div>
<!-- Warning banner -->
<div class="rounded-xl p-4 mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<div class="flex items-start gap-3">
<span class="text-2xl">⚠️</span>
<div class="text-sm text-red-900 dark:text-red-200">
<strong class="font-semibold">Destructive operations.</strong>
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.
</div>
</div>
</div>
<!-- Filters -->
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-5 mb-4">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Filters</h2>
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Serial</label>
<input type="text" id="f-serial" placeholder="e.g. BE9558"
onkeydown="if (event.key === 'Enter') loadEvents()"
class="px-3 py-1.5 text-sm font-mono w-40 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
<input type="datetime-local" id="f-from"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">To</label>
<input type="datetime-local" id="f-to"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">False Triggers</label>
<select id="f-ft" class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="">All</option>
<option value="false">Real Only</option>
<option value="true">FT Only</option>
</select>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Limit</label>
<select id="f-limit" class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="100">100</option>
<option value="500" selected>500</option>
<option value="1000">1000</option>
<option value="5000">5000</option>
</select>
</div>
<button onclick="loadEvents()"
class="px-4 py-1.5 text-sm font-medium rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
Apply
</button>
<button onclick="clearFilters()"
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
Clear
</button>
</div>
</div>
<!-- Bulk actions -->
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-4 flex flex-wrap items-center gap-3">
<span id="bulk-selected" class="text-sm text-gray-600 dark:text-gray-400">0 selected</span>
<button id="bulk-delete-selected" onclick="deleteSelected()" disabled
class="px-3 py-1.5 text-sm rounded-lg border border-red-300 dark:border-red-700 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30 disabled:opacity-40 disabled:cursor-not-allowed">
🗑 Delete selected
</button>
<button onclick="deleteByFilter()"
class="px-3 py-1.5 text-sm rounded-lg border border-red-400 dark:border-red-600 bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-200 hover:bg-red-100 dark:hover:bg-red-900/50 font-medium">
🗑 Delete ALL matching current filter…
</button>
<div class="border-l border-gray-300 dark:border-gray-600 h-6"></div>
<button id="bulk-flag-ft" onclick="flagSelected(true)" disabled
class="px-3 py-1.5 text-sm rounded-lg border border-yellow-300 dark:border-yellow-700 text-yellow-700 dark:text-yellow-300 hover:bg-yellow-50 dark:hover:bg-yellow-900/30 disabled:opacity-40 disabled:cursor-not-allowed">
🚩 Flag as FT
</button>
<button id="bulk-clear-ft" onclick="flagSelected(false)" disabled
class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed">
✓ Clear FT
</button>
</div>
<!-- Results -->
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg overflow-hidden">
<div class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700" id="results-summary">
Apply filters above to load events.
</div>
<div id="results-table" class="overflow-x-auto">
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No query run yet.</div>
</div>
</div>
<script>
const SFM_PROXY = '/api/sfm';
const _selected = new Set();
let _events = [];
function _esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _fmtPpv(v) { return (v === null || v === undefined) ? '—' : Number(v).toFixed(4); }
function _refreshBulkButtons() {
const n = _selected.size;
document.getElementById('bulk-selected').textContent = `${n} selected`;
document.getElementById('bulk-delete-selected').disabled = (n === 0);
document.getElementById('bulk-flag-ft').disabled = (n === 0);
document.getElementById('bulk-clear-ft').disabled = (n === 0);
}
function _currentFilter() {
const f = {};
const serial = document.getElementById('f-serial').value.trim();
const from = document.getElementById('f-from').value;
const to = document.getElementById('f-to').value;
const ft = document.getElementById('f-ft').value;
if (serial) f.serial = serial;
if (from) f.from_dt = from;
if (to) f.to_dt = to;
if (ft === 'true') f.false_trigger = true;
if (ft === 'false') f.false_trigger = false;
return f;
}
function clearFilters() {
document.getElementById('f-serial').value = '';
document.getElementById('f-from').value = '';
document.getElementById('f-to').value = '';
document.getElementById('f-ft').value = '';
loadEvents();
}
async function loadEvents() {
const f = _currentFilter();
const limit = document.getElementById('f-limit').value || '500';
const params = new URLSearchParams();
if (f.serial) params.set('serial', f.serial);
if (f.from_dt) params.set('from_dt', f.from_dt);
if (f.to_dt) params.set('to_dt', f.to_dt);
if (f.false_trigger !== undefined) params.set('false_trigger', String(f.false_trigger));
params.set('limit', limit);
const container = document.getElementById('results-table');
const summary = document.getElementById('results-summary');
container.innerHTML = '<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">Loading…</div>';
try {
const resp = await fetch(`${SFM_PROXY}/db/events?${params.toString()}`);
if (!resp.ok) {
container.innerHTML = `<div class="text-center py-8 text-red-600 dark:text-red-400 text-sm">Load failed: HTTP ${resp.status}</div>`;
return;
}
const data = await resp.json();
_events = data.events || [];
summary.textContent = `Showing ${_events.length} event${_events.length === 1 ? '' : 's'} (limit ${limit})`;
renderTable();
} catch (err) {
container.innerHTML = `<div class="text-center py-8 text-red-600 dark:text-red-400 text-sm">Load failed: ${_esc(err.message || err)}</div>`;
}
}
function renderTable() {
const container = document.getElementById('results-table');
if (_events.length === 0) {
container.innerHTML = '<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">No events match the current filter.</div>';
_refreshBulkButtons();
return;
}
const rows = _events.map(ev => {
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
const ft = ev.false_trigger
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
: '';
const checked = _selected.has(ev.id) ? 'checked' : '';
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td class="px-3 py-2"><input type="checkbox" class="row-check" data-event-id="${_esc(ev.id)}" ${checked} onchange="onRowCheck(this)"></td>
<td class="px-3 py-2 text-sm font-mono text-gray-700 dark:text-gray-300">${_esc(ev.serial)}</td>
<td class="px-3 py-2 text-sm text-gray-900 dark:text-white whitespace-nowrap">${_esc(ts)}</td>
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.tran_ppv)}</td>
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.vert_ppv)}</td>
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.long_ppv)}</td>
<td class="px-3 py-2 text-sm font-mono text-right font-semibold">${_fmtPpv(ev.peak_vector_sum)}</td>
<td class="px-3 py-2 text-sm">${ft}</td>
<td class="px-3 py-2 text-sm font-mono text-gray-500 dark:text-gray-400 truncate" style="max-width:240px;" title="${_esc(ev.id)}">${_esc(ev.id).slice(0, 8)}…</td>
</tr>`;
}).join('');
container.innerHTML = `
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th class="px-3 py-2"><input type="checkbox" id="check-all" onchange="toggleAllRows(this.checked)"></th>
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Serial</th>
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Timestamp</th>
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">Tran</th>
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">Vert</th>
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">Long</th>
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 text-right">PVS</th>
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Flags</th>
<th class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">ID</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
</table>`;
_refreshBulkButtons();
}
function onRowCheck(input) {
const id = input.getAttribute('data-event-id');
if (input.checked) _selected.add(id);
else {
_selected.delete(id);
const master = document.getElementById('check-all');
if (master) master.checked = false;
}
_refreshBulkButtons();
}
function toggleAllRows(checked) {
document.querySelectorAll('.row-check').forEach(cb => {
const id = cb.getAttribute('data-event-id');
cb.checked = checked;
if (checked) _selected.add(id);
else _selected.delete(id);
});
_refreshBulkButtons();
}
async function deleteSelected() {
if (_selected.size === 0) return;
const ids = Array.from(_selected);
if (!confirm(`PERMANENTLY delete ${ids.length} event${ids.length === 1 ? '' : 's'} from the SFM DB?\n\nAlso removes associated waveform/sidecar files on disk.\nThis cannot be undone.`)) {
return;
}
try {
const resp = await fetch(`${SFM_PROXY}/db/events/delete_bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, confirm: true }),
});
const data = await resp.json();
if (!resp.ok) {
alert(`Delete failed: HTTP ${resp.status}\n${JSON.stringify(data)}`);
return;
}
alert(`Deleted ${data.deleted} event${data.deleted === 1 ? '' : 's'}. Removed ${data.files_removed} file${data.files_removed === 1 ? '' : 's'} from disk.`);
_selected.clear();
loadEvents();
} catch (err) {
alert(`Delete failed: ${err.message || err}`);
}
}
async function deleteByFilter() {
const f = _currentFilter();
if (Object.keys(f).length === 0) {
if (!confirm('No filters set — this would attempt to delete EVERY event in the SFM DB.\n\nAre you absolutely sure? You probably want a serial filter at minimum.')) {
return;
}
}
// Dry-run first
let matched = 0;
let sample_serials = [];
try {
const dry = await fetch(`${SFM_PROXY}/db/events/delete_bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(f),
});
const dryData = await dry.json();
if (!dry.ok) {
alert(`Dry-run failed: HTTP ${dry.status}\n${JSON.stringify(dryData)}`);
return;
}
matched = dryData.matched || 0;
sample_serials = dryData.sample_serials || [];
} catch (err) {
alert(`Dry-run failed: ${err.message || err}`);
return;
}
if (matched === 0) {
alert('No events match the current filter — nothing to delete.');
return;
}
const filterLines = Object.entries(f).map(([k, v]) => ` ${k} = ${v}`).join('\n') || ' (none)';
const serialList = sample_serials.length ? ` serials: ${sample_serials.join(', ')}${sample_serials.length === 5 ? ' (and possibly more)' : ''}\n` : '';
if (!confirm(`PERMANENTLY delete ${matched} event${matched === 1 ? '' : 's'}?\n\nFilter:\n${filterLines}\n${serialList}\nAlso removes associated waveform/sidecar files on disk.\nThis cannot be undone.`)) {
return;
}
try {
const resp = await fetch(`${SFM_PROXY}/db/events/delete_bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...f, confirm: true }),
});
const data = await resp.json();
if (!resp.ok) {
alert(`Delete failed: HTTP ${resp.status}\n${JSON.stringify(data)}`);
return;
}
if (data.status === 'too_many') {
alert(`Refused: ${data.hint}`);
return;
}
alert(`Deleted ${data.deleted} event${data.deleted === 1 ? '' : 's'}. Removed ${data.files_removed} file${data.files_removed === 1 ? '' : 's'} from disk.`);
_selected.clear();
loadEvents();
} catch (err) {
alert(`Delete failed: ${err.message || err}`);
}
}
async function flagSelected(value) {
if (_selected.size === 0) return;
const ids = Array.from(_selected);
const verb = value ? 'flag as false trigger' : 'clear false-trigger flag on';
if (!confirm(`${verb} ${ids.length} event${ids.length === 1 ? '' : 's'}?`)) return;
let ok = 0, failed = 0, cursor = 0;
async function worker() {
while (cursor < ids.length) {
const i = cursor++;
const id = ids[i];
try {
const r = await fetch(`${SFM_PROXY}/db/events/${encodeURIComponent(id)}/false_trigger?value=${value ? 'true' : 'false'}`,
{ method: 'PATCH' });
if (r.ok) ok++; else failed++;
} catch (_) { failed++; }
}
}
await Promise.all(Array.from({ length: 8 }, worker));
if (failed) alert(`${ok} updated, ${failed} failed.`);
_selected.clear();
loadEvents();
}
// Initial empty state — let the user choose to load.
</script>
{% endblock %}
+12 -8
View File
@@ -210,7 +210,11 @@
</main>
</div>
<!-- Bottom Navigation (Mobile Only) -->
<!-- Bottom Navigation (Mobile Only) — primary field-work shortcuts.
Deploy is here because the whole point of the workflow is "I'm
on site, capture this install in 90s before I leave." Devices,
Settings, Projects, Job Planner reachable via the hamburger
Menu (slot 1) which opens the full sidebar drawer. -->
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
<div class="grid grid-cols-4 h-16">
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
@@ -225,18 +229,18 @@
</svg>
<span>Dashboard</span>
</button>
<button class="bottom-nav-btn" data-href="/roster" onclick="window.location.href='/roster'">
<button class="bottom-nav-btn" data-href="/deploy" onclick="window.location.href='/deploy'">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>Devices</span>
<span>Deploy</span>
</button>
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
<button class="bottom-nav-btn" data-href="/sfm" onclick="window.location.href='/sfm'">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
<span>Settings</span>
<span>Events</span>
</button>
</div>
</nav>
+62 -4
View File
@@ -10,17 +10,75 @@
</div>
{% endif %}
<div class="mb-8 flex justify-between items-center">
<div class="mb-8 flex justify-between items-center gap-4 flex-wrap">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
</div>
<div class="text-right">
<p class="text-xs text-gray-500 dark:text-gray-400">Last updated</p>
<p id="last-refresh" class="text-sm text-gray-700 dark:text-gray-300 font-mono">--</p>
<div class="flex items-center gap-3">
<a href="/deploy"
class="hidden md:inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-sm shadow"
title="Capture a field install — pick unit, snap photo, leave">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Field Deploy
</a>
<div class="text-right">
<p class="text-xs text-gray-500 dark:text-gray-400">Last updated</p>
<p id="last-refresh" class="text-sm text-gray-700 dark:text-gray-300 font-mono">--</p>
</div>
</div>
</div>
<!-- Pending-deployments banner — auto-shows when there are field captures
awaiting classification. Hides itself when count is 0. Polled
alongside the rest of the dashboard's 10-second refresh. -->
<a id="pending-deploy-banner" href="/tools/pending-deployments"
class="hidden mb-6 flex items-center justify-between gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-amber-100 dark:bg-amber-900/40 text-amber-600 dark:text-amber-400 flex items-center justify-center shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<div>
<div class="text-sm font-medium text-amber-900 dark:text-amber-200">
<span id="pending-deploy-count">0</span> field deployment<span id="pending-deploy-plural">s</span> awaiting classification
</div>
<div class="text-xs text-amber-700 dark:text-amber-300">Click to pick project / location for these captures</div>
</div>
</div>
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
<script>
async function _refreshPendingDeployBanner() {
try {
const r = await fetch('/api/deployments/pending?status=awaiting');
if (!r.ok) return;
const data = await r.json();
const banner = document.getElementById('pending-deploy-banner');
const countEl = document.getElementById('pending-deploy-count');
const pluralEl = document.getElementById('pending-deploy-plural');
if (data.count > 0) {
countEl.textContent = data.count;
pluralEl.textContent = data.count === 1 ? '' : 's';
banner.classList.remove('hidden');
} else {
banner.classList.add('hidden');
}
} catch (e) {
/* silent — banner just stays hidden */
}
}
_refreshPendingDeployBanner();
setInterval(_refreshPendingDeployBanner, 30000);
</script>
<!-- Dashboard cards with auto-refresh -->
<div hx-get="/api/status-snapshot"
hx-trigger="load, every 10s"
+300
View File
@@ -0,0 +1,300 @@
{% extends "base.html" %}
{% block title %}Field Deploy - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="max-w-md mx-auto">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Field Deploy</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Capture an install while you're still on site. Project + location can be picked later at a desk.
</p>
</div>
<!-- Stepper -->
<div class="flex items-center justify-between mb-6 text-xs text-gray-500 dark:text-gray-400">
<div id="step-pill-1" class="flex items-center gap-1 text-seismo-orange font-medium">
<span class="w-6 h-6 rounded-full bg-seismo-orange text-white inline-flex items-center justify-center text-xs">1</span>
Unit
</div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-2"></div>
<div id="step-pill-2" class="flex items-center gap-1">
<span class="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center">2</span>
Photo
</div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-2"></div>
<div id="step-pill-3" class="flex items-center gap-1">
<span class="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center">3</span>
Confirm
</div>
</div>
<!-- Step 1: Pick unit -->
<div id="step-1" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Which seismograph?</span>
<input id="unit-search" type="search" autocomplete="off"
placeholder="Type a serial like BE12599…"
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
oninput="onUnitSearch()">
</label>
<div id="unit-list" class="max-h-72 overflow-y-auto space-y-1.5"></div>
</div>
<!-- Step 2: Photo -->
<div id="step-2" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Deploying</p>
<p class="font-mono font-semibold text-seismo-orange" id="step2-unit-label"></p>
</div>
<button onclick="goToStep(1)" class="text-xs text-gray-500 hover:text-seismo-orange">Change</button>
</div>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Install photo</span>
<input id="photo-input" type="file" accept="image/*"
onchange="onPhotoPicked(event)"
class="mt-2 w-full text-sm text-gray-500 file:mr-4 file:py-3 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-seismo-orange file:text-white hover:file:bg-orange-600">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Take a new photo or pick a previously taken one. EXIF GPS is auto-extracted either way.
</p>
</label>
<div id="photo-preview-wrap" class="hidden">
<img id="photo-preview" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" alt="Install photo preview">
</div>
</div>
<!-- Step 3: Note + confirm -->
<div id="step-3" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Deploying</p>
<p class="font-mono font-semibold text-seismo-orange" id="step3-unit-label"></p>
</div>
<div id="step3-photo-wrap" class="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
<img id="step3-photo" class="w-full" alt="Install photo">
</div>
<div id="step3-coords-status" class="text-xs"></div>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Site memo (optional)</span>
<textarea id="note-input" rows="3"
placeholder="e.g. Carnegie Museum, north entrance loading dock"
class="mt-2 w-full px-3 py-2 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Helpful when classifying later. Free text — anything that helps you remember.
</p>
</label>
<div id="capture-error" class="hidden text-sm text-red-600"></div>
<button id="capture-submit"
onclick="submitCapture()"
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base">
Capture
</button>
<button onclick="goToStep(2)" class="w-full text-sm text-gray-500 hover:text-seismo-orange">← Retake photo</button>
</div>
<!-- Success -->
<div id="step-done" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4 text-center">
<div class="w-16 h-16 mx-auto rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Captured</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
<span id="done-unit-label" class="font-mono font-semibold text-seismo-orange"></span>
is now in the pending hopper.
You can classify it from <a href="/tools/pending-deployments" class="text-seismo-orange hover:text-seismo-navy underline">Tools → Pending Deployments</a> later.
</p>
<p class="text-xs text-gray-400 mt-2" id="done-coords-label"></p>
</div>
<div class="grid grid-cols-2 gap-2">
<button onclick="resetWizard()"
class="px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
Deploy another
</button>
<a href="/tools/pending-deployments"
class="px-4 py-3 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium inline-flex items-center justify-center">
View pending
</a>
</div>
</div>
</div>
<script>
let _deployState = {
unit_id: null,
photo_file: null,
photo_preview_url: null,
captured: null, // server response from /capture
};
let _unitSearchDebounce = null;
async function onUnitSearch() {
if (_unitSearchDebounce) clearTimeout(_unitSearchDebounce);
_unitSearchDebounce = setTimeout(_fetchUnitList, 150);
}
async function _fetchUnitList() {
const q = document.getElementById('unit-search').value.trim();
const list = document.getElementById('unit-list');
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Searching…</p>';
try {
const r = await fetch(`/api/deployments/seismograph-picker?q=${encodeURIComponent(q)}&limit=20`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
_renderUnitList(data.units || []);
} catch (e) {
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Search failed: ${e.message}</p>`;
}
}
function _renderUnitList(units) {
const list = document.getElementById('unit-list');
if (units.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No matching units.</p>';
return;
}
list.innerHTML = units.map(u => {
const noteHtml = u.note ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.note)}</div>` : '';
const pendingBadge = u.has_pending
? '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 ml-2">already pending</span>'
: '';
return `<button onclick="onPickUnit('${_esc(u.id)}')"
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="flex items-center justify-between gap-2">
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(u.id)}</span>
${pendingBadge}
</div>
${noteHtml}
</button>`;
}).join('');
}
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function onPickUnit(unitId) {
_deployState.unit_id = unitId;
document.getElementById('step2-unit-label').textContent = unitId;
document.getElementById('step3-unit-label').textContent = unitId;
goToStep(2);
}
function onPhotoPicked(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
_deployState.photo_file = file;
// Show preview.
if (_deployState.photo_preview_url) URL.revokeObjectURL(_deployState.photo_preview_url);
_deployState.photo_preview_url = URL.createObjectURL(file);
document.getElementById('photo-preview').src = _deployState.photo_preview_url;
document.getElementById('step3-photo').src = _deployState.photo_preview_url;
document.getElementById('photo-preview-wrap').classList.remove('hidden');
// Advance after a tiny delay so the user sees the preview.
setTimeout(() => goToStep(3), 400);
}
function goToStep(n) {
for (let i = 1; i <= 3; i++) {
const el = document.getElementById('step-' + i);
if (el) el.classList.toggle('hidden', i !== n);
const pill = document.getElementById('step-pill-' + i);
if (pill) {
if (i === n) {
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
pill.classList.add('text-seismo-orange', 'font-medium');
pill.querySelector('span').classList.remove('bg-gray-200', 'dark:bg-gray-700');
pill.querySelector('span').classList.add('bg-seismo-orange', 'text-white');
} else {
pill.classList.add('text-gray-500', 'dark:text-gray-400');
pill.classList.remove('text-seismo-orange', 'font-medium');
pill.querySelector('span').classList.add('bg-gray-200', 'dark:bg-gray-700');
pill.querySelector('span').classList.remove('bg-seismo-orange', 'text-white');
}
}
}
document.getElementById('step-done').classList.add('hidden');
}
async function submitCapture() {
const btn = document.getElementById('capture-submit');
const err = document.getElementById('capture-error');
const note = document.getElementById('note-input').value.trim();
err.classList.add('hidden');
if (!_deployState.unit_id || !_deployState.photo_file) {
err.textContent = 'Need a unit and a photo first.';
err.classList.remove('hidden');
return;
}
btn.disabled = true;
btn.textContent = 'Capturing…';
const fd = new FormData();
fd.append('unit_id', _deployState.unit_id);
fd.append('operator_note', note);
fd.append('photo', _deployState.photo_file);
try {
const r = await fetch('/api/deployments/capture', {
method: 'POST',
body: fd,
});
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
const data = await r.json();
_deployState.captured = data;
_showDone(data);
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Capture';
}
}
function _showDone(data) {
for (let i = 1; i <= 3; i++) {
document.getElementById('step-' + i).classList.add('hidden');
}
document.getElementById('step-done').classList.remove('hidden');
document.getElementById('done-unit-label').textContent = data.pending_deployment.unit_id;
const coords = data.extracted_coords;
const status = document.getElementById('done-coords-label');
if (coords) {
status.textContent = `GPS: ${coords}`;
} else {
status.textContent = 'No GPS in photo EXIF — you can add coordinates when classifying.';
}
}
function resetWizard() {
_deployState = { unit_id: null, photo_file: null, photo_preview_url: null, captured: null };
document.getElementById('unit-search').value = '';
document.getElementById('note-input').value = '';
document.getElementById('photo-input').value = '';
document.getElementById('photo-preview-wrap').classList.add('hidden');
document.getElementById('capture-error').classList.add('hidden');
const btn = document.getElementById('capture-submit');
btn.disabled = false; btn.textContent = 'Capture';
goToStep(1);
_fetchUnitList();
}
// Kick off initial unit list on page load.
_fetchUnitList();
</script>
{% endblock %}
@@ -0,0 +1,159 @@
<!-- Reusable project-location map.
Renders a Leaflet map with one pin per active monitoring location for
the given project. Fetches data from /api/projects/{p}/locations-json
on load.
Required context variable:
project_id — UUID string
Optional context variable:
map_height — CSS height (default "320px")
location_type — filter the fetched list (default: all types)
Hover any .location-card on the page with a matching
data-location-id → highlights the pin. Click a pin → scrolls
the matching card into view + flashes an orange ring.
isolation:isolate on the container forces a new stacking context so
Leaflet's internal z-indexes (200-800) stay contained inside the
card instead of leaking out and rendering over modals (z-50).
-->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Location Map</h3>
<span class="text-xs text-gray-400 dark:text-gray-500" id="loc-map-status-{{ project_id }}"></span>
</div>
<div id="loc-map-{{ project_id }}"
class="w-full rounded-lg border border-gray-200 dark:border-gray-700"
style="height: {{ map_height | default('320px') }}; background: rgba(0,0,0,0.05); isolation: isolate;">
</div>
<div id="loc-map-empty-{{ project_id }}" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2 italic text-center">
No location coordinates set. Edit a location and add a <code class="font-mono">lat,lon</code> pair to see it here.
</div>
<div id="loc-map-missing-{{ project_id }}" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
</div>
<script>
(function () {
const projectId = {{ project_id | tojson }};
const locationType = {{ (location_type | default(none)) | tojson }};
const mapEl = document.getElementById('loc-map-' + projectId);
const emptyMsg = document.getElementById('loc-map-empty-' + projectId);
const missingMsg = document.getElementById('loc-map-missing-' + projectId);
const statusEl = document.getElementById('loc-map-status-' + projectId);
if (!mapEl) return;
function parseCoords(s) {
if (!s) return null;
const parts = String(s).split(',').map(x => parseFloat(x.trim()));
if (parts.length !== 2 || parts.some(isNaN)) return null;
const [lat, lon] = parts;
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
return [lat, lon];
}
async function init() {
statusEl.textContent = 'loading…';
let locs;
try {
const qs = locationType ? `?location_type=${encodeURIComponent(locationType)}` : '';
const r = await fetch(`/api/projects/${projectId}/locations-json${qs}`);
if (!r.ok) throw new Error('HTTP ' + r.status);
locs = await r.json();
} catch (e) {
statusEl.textContent = 'failed';
mapEl.innerHTML = `<div class="flex items-center justify-center h-full text-sm text-red-500">Map load failed: ${e.message}</div>`;
return;
}
statusEl.textContent = '';
const withCoords = [];
const withoutCoords = [];
for (const loc of locs) {
const xy = parseCoords(loc.coordinates);
if (xy) withCoords.push({ ...loc, latlon: xy });
else withoutCoords.push(loc);
}
if (withCoords.length === 0) {
mapEl.classList.add('hidden');
emptyMsg.classList.remove('hidden');
return;
}
const map = L.map(mapEl, { scrollWheelZoom: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 18,
}).addTo(map);
const markersById = {};
const bounds = [];
withCoords.forEach(loc => {
const marker = L.circleMarker(loc.latlon, {
radius: 8, fillColor: '#f48b1c', color: '#fff',
weight: 2, opacity: 1, fillOpacity: 0.9,
}).addTo(map);
marker.bindTooltip(loc.name, { direction: 'top', offset: [0, -6] });
marker.on('click', () => _lmFlashCard(loc.id));
markersById[loc.id] = marker;
bounds.push(loc.latlon);
});
if (bounds.length === 1) map.setView(bounds[0], 14);
else map.fitBounds(bounds, { padding: [20, 20] });
setTimeout(() => map.invalidateSize(), 100);
if (withoutCoords.length > 0) {
const names = withoutCoords.map(l => l.name).join(', ');
missingMsg.textContent = `${withoutCoords.length} location${withoutCoords.length === 1 ? '' : 's'} not shown (no coordinates): ${names}`;
missingMsg.classList.remove('hidden');
}
// Hover any .location-card on the page → highlight matching pin.
let hoverPinId = null;
document.addEventListener('mouseover', (e) => {
const card = e.target.closest('.location-card');
if (!card) return;
const locId = card.dataset.locationId;
if (!markersById[locId] || locId === hoverPinId) return;
if (hoverPinId) _unhighlight(hoverPinId);
_highlight(locId);
hoverPinId = locId;
});
document.addEventListener('mouseout', (e) => {
const card = e.target.closest('.location-card');
if (!card) return;
const related = e.relatedTarget && e.relatedTarget.closest('.location-card');
if (related === card) return;
if (hoverPinId) {
_unhighlight(hoverPinId);
hoverPinId = null;
}
});
function _highlight(locId) {
const m = markersById[locId]; if (!m) return;
m.setStyle({ radius: 12, fillColor: '#dc2626', weight: 3 });
m.openTooltip();
m.bringToFront();
}
function _unhighlight(locId) {
const m = markersById[locId]; if (!m) return;
m.setStyle({ radius: 8, fillColor: '#f48b1c', weight: 2 });
m.closeTooltip();
}
}
function _lmFlashCard(locId) {
const card = document.querySelector(`.location-card[data-location-id="${locId}"]`);
if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('ring-2', 'ring-seismo-orange');
setTimeout(() => card.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
}
init();
})();
</script>
@@ -78,128 +78,19 @@
</div>
</div>
<!-- Location Map — replaces the old Upcoming Actions panel for the
overview. Operators get a quick visual of where their locations
sit relative to each other. Pins clickable → scroll to + flash
the matching card. Locations without coordinates land in a
"missing coords" hint below the map.
For projects with scheduled monitoring activity, the full
Upcoming Actions list is still available on the Schedules tab. -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Location Map</h3>
{% if upcoming_actions %}
<a href="javascript:void(0)" onclick="switchTab('schedules')"
class="text-xs text-seismo-orange hover:text-seismo-navy whitespace-nowrap">
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
</a>
{% endif %}
</div>
<!-- `isolation: isolate` forces a new stacking context so Leaflet's
internal z-indexes (panes at 200-700, controls at 800) stay
contained inside this div instead of leaking into the root
stacking context and rendering over modals (which have z-50). -->
<div id="project-location-map" class="w-full rounded-lg border border-gray-200 dark:border-gray-700"
style="height: 320px; background: rgba(0,0,0,0.05); isolation: isolate;"></div>
<div id="project-location-map-empty" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2 italic text-center">
No location coordinates set. Edit a location and add a <code class="font-mono">lat,lon</code> pair to see it here.
</div>
<div id="project-location-map-missing" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
</div>
{# 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 %}
</div>
<script>
(function () {
// Build location data from server-side render. Skip removed
// locations (their pins would clutter the active operations view)
// and skip ones without parseable coordinates.
const locationsRaw = [
{% for loc in locations %}
{% if not loc.removed_at %}
{
id: {{ loc.id | tojson }},
name: {{ loc.name | tojson }},
coords: {{ loc.coordinates | tojson if loc.coordinates else 'null' }},
}{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
];
function parseCoords(s) {
if (!s) return null;
const parts = String(s).split(',').map(x => parseFloat(x.trim()));
if (parts.length !== 2 || parts.some(isNaN)) return null;
const [lat, lon] = parts;
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
return [lat, lon];
}
const withCoords = [];
const withoutCoords = [];
for (const loc of locationsRaw) {
const xy = parseCoords(loc.coords);
if (xy) withCoords.push({ ...loc, latlon: xy });
else withoutCoords.push(loc);
}
const emptyMsg = document.getElementById('project-location-map-empty');
const missingMsg = document.getElementById('project-location-map-missing');
const mapEl = document.getElementById('project-location-map');
if (!mapEl) return;
if (withCoords.length === 0) {
// Hide the map block and show a hint. Don't init Leaflet at all.
mapEl.classList.add('hidden');
emptyMsg.classList.remove('hidden');
return;
}
// Initialise Leaflet. `L` is loaded globally by base.html.
const map = L.map(mapEl, { scrollWheelZoom: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 18,
}).addTo(map);
const markers = [];
const bounds = [];
withCoords.forEach(loc => {
const marker = L.circleMarker(loc.latlon, {
radius: 8,
fillColor: '#f48b1c',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.9,
}).addTo(map);
marker.bindTooltip(loc.name, { direction: 'top', offset: [0, -6] });
marker.on('click', () => _flashLocationCard(loc.id));
markers.push(marker);
bounds.push(loc.latlon);
});
if (bounds.length === 1) {
map.setView(bounds[0], 14);
} else {
map.fitBounds(bounds, { padding: [20, 20] });
}
// Without this the map renders into a 0×0 area when the partial
// first lands via htmx (container size not yet stable).
setTimeout(() => map.invalidateSize(), 100);
if (withoutCoords.length > 0) {
const names = withoutCoords.map(l => l.name).join(', ');
missingMsg.textContent = `${withoutCoords.length} location${withoutCoords.length === 1 ? '' : 's'} not shown (no coordinates): ${names}`;
missingMsg.classList.remove('hidden');
}
// Briefly highlight the matching card to confirm the click.
function _flashLocationCard(locId) {
const card = document.querySelector(`.location-card[data-location-id="${locId}"]`);
if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('ring-2', 'ring-seismo-orange');
setTimeout(() => card.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
}
})();
</script>
{% if upcoming_actions %}
<div class="mt-3 text-xs text-right text-gray-500 dark:text-gray-400">
<a href="javascript:void(0)" onclick="switchTab('schedules')"
class="text-seismo-orange hover:text-seismo-navy">
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
</a>
</div>
{% endif %}
+26 -15
View File
@@ -101,22 +101,33 @@
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
<button onclick="openLocationModal('vibration')"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Add Location
</button>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 lg:col-span-2">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
<button onclick="openLocationModal('vibration')"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Add Location
</button>
</div>
<div id="vibration-locations"
hx-get="/api/projects/{{ project_id }}/locations?location_type=vibration"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading locations...</div>
</div>
</div>
<div id="vibration-locations"
hx-get="/api/projects/{{ project_id }}/locations?location_type=vibration"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading locations...</div>
{# 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. #}
<div>
{% with project_id=project_id, location_type='vibration', map_height='450px' %}
{% include 'partials/projects/location_map.html' %}
{% endwith %}
</div>
</div>
</div>
+14
View File
@@ -589,6 +589,20 @@
</a>
</div>
<!-- SFM Event DB Manager -->
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
<div>
<div class="font-medium text-gray-900 dark:text-white">SFM Event DB Manager</div>
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Browse, flag, and <strong>delete</strong> 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.
</div>
</div>
<a href="/admin/events"
class="ml-6 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
Open
</a>
</div>
{# Metadata Backfill + Project Tidy moved to Tools (they're
operator workflows, not admin/dev surfaces). Find them
at /tools. #}
+55
View File
@@ -14,6 +14,43 @@
<!-- Card grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Field Deploy (mobile-first) -->
<a href="/deploy"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-orange-100 dark:bg-orange-900/30 text-seismo-orange flex items-center justify-center shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-white">Field Deploy</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
On site? Pick a unit, snap an install photo, leave. GPS is auto-captured. Classify the project/location later from a desk.
</p>
</div>
</div>
</a>
<!-- Pending Deployments (the hopper) -->
<a href="/tools/pending-deployments"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 flex items-center justify-center shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-white">Pending Deployments</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Field captures waiting to be classified into a project + location.
</p>
</div>
</div>
</a>
<!-- Pair Devices -->
<a href="/pair-devices"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
@@ -68,6 +105,24 @@
</div>
</a>
<!-- Deployment History -->
<a href="/tools/deployment-history"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-white">Deployment History</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
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.
</p>
</div>
</div>
</a>
<!-- Reports (per-project) -->
<a href="/projects"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
+104
View File
@@ -379,6 +379,20 @@
</button>
</div>
<!-- Bulk false-trigger flagging -->
<div id="ue-bulk-actions" class="flex flex-wrap items-center gap-2 mb-3 text-sm">
<span id="ue-bulk-selected" class="text-gray-600 dark:text-gray-400">0 selected</span>
<button id="ue-bulk-flag-ft" onclick="flagSelectedUnitEvents(true)" disabled
class="px-3 py-1.5 text-sm rounded-lg border border-yellow-300 dark:border-yellow-700 text-yellow-700 dark:text-yellow-300 hover:bg-yellow-50 dark:hover:bg-yellow-900/30 disabled:opacity-40 disabled:cursor-not-allowed">
🚩 Flag as false trigger
</button>
<button id="ue-bulk-clear-ft" onclick="flagSelectedUnitEvents(false)" disabled
class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed">
✓ Clear false trigger
</button>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">For deletion / DB cleanup, use the <a href="/admin/events" class="text-seismo-orange hover:underline">Event DB Manager</a>.</span>
</div>
<!-- Event table -->
<div id="ue-events-container" class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">
@@ -2652,7 +2666,13 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
: '';
const checked = _ueSelectedEventIds.has(ev.id) ? 'checked' : '';
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer ${ev.attribution ? '' : 'bg-amber-50/40 dark:bg-amber-900/10'}" onclick="showEventDetail('${_dtEsc(ev.id)}')">
<td class="px-3 py-2.5 text-sm" onclick="event.stopPropagation()">
<input type="checkbox" class="ue-row-check rounded border-gray-300 dark:border-gray-600"
data-event-id="${_dtEsc(ev.id)}" ${checked}
onchange="onUnitEventRowCheck(this)">
</td>
<td class="px-4 py-2.5 text-sm text-gray-900 dark:text-white whitespace-nowrap">${ts}</td>
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.tran_ppv)}">${tran}</td>
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.vert_ppv)}">${vert}</td>
@@ -2668,6 +2688,11 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th class="px-3 py-2">
<input type="checkbox" id="ue-check-all"
class="rounded border-gray-300 dark:border-gray-600"
onchange="toggleAllUnitEventRows(this.checked)">
</th>
${_ueSortableTh('Timestamp', 'timestamp')}
${_ueSortableTh('Tran', 'tran_ppv')}
${_ueSortableTh('Vert', 'vert_ppv')}
@@ -2679,6 +2704,85 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
</table>`;
_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 =====