Compare commits
5 Commits
7ed94cd8fc
...
ef6484c350
| Author | SHA1 | Date | |
|---|---|---|---|
| ef6484c350 | |||
| 8cffd7dd5e | |||
| ba4cf9e560 | |||
| 1af5a94f57 | |||
| e05f2189c4 |
@@ -112,6 +112,9 @@ app.include_router(admin_modules.router)
|
|||||||
from backend.routers import deployment_history
|
from backend.routers import deployment_history
|
||||||
app.include_router(deployment_history.router)
|
app.include_router(deployment_history.router)
|
||||||
|
|
||||||
|
from backend.routers import pending_deployments
|
||||||
|
app.include_router(pending_deployments.router)
|
||||||
|
|
||||||
# Projects system routers
|
# Projects system routers
|
||||||
app.include_router(projects.router)
|
app.include_router(projects.router)
|
||||||
app.include_router(project_locations.router)
|
app.include_router(project_locations.router)
|
||||||
@@ -272,6 +275,22 @@ async def tools_page(request: Request):
|
|||||||
return templates.TemplateResponse("tools.html", {"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)
|
@app.get("/modems", response_class=HTMLResponse)
|
||||||
async def modems_page(request: Request):
|
async def modems_page(request: Request):
|
||||||
"""Field modems management dashboard"""
|
"""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.")
|
||||||
@@ -644,3 +644,58 @@ class JobReservationUnit(Base):
|
|||||||
# Location identity
|
# Location identity
|
||||||
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
|
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
|
||||||
slot_index = Column(Integer, nullable=True) # Order within reservation (0-based)
|
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)
|
||||||
|
|||||||
@@ -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")
|
@router.get("/api/admin/sfm/overview")
|
||||||
async def admin_sfm_overview() -> JSONResponse:
|
async def admin_sfm_overview() -> JSONResponse:
|
||||||
"""Aggregated SFM diagnostic snapshot.
|
"""Aggregated SFM diagnostic snapshot.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 %}
|
||||||
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||||
+8
-5
@@ -211,8 +211,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Navigation (Mobile Only) — primary field-work shortcuts.
|
<!-- Bottom Navigation (Mobile Only) — primary field-work shortcuts.
|
||||||
Settings + Projects + Job Planner are reachable via the
|
Deploy is here because the whole point of the workflow is "I'm
|
||||||
hamburger Menu (slot 1) which opens the full sidebar drawer. -->
|
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 %}">
|
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
||||||
<div class="grid grid-cols-4 h-16">
|
<div class="grid grid-cols-4 h-16">
|
||||||
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||||
@@ -227,11 +229,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</button>
|
</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">
|
<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>
|
</svg>
|
||||||
<span>Devices</span>
|
<span>Deploy</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="bottom-nav-btn" data-href="/sfm" onclick="window.location.href='/sfm'">
|
<button class="bottom-nav-btn" data-href="/sfm" onclick="window.location.href='/sfm'">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -10,17 +10,75 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mb-8 flex justify-between items-center">
|
<div class="mb-8 flex justify-between items-center gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
<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>
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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">
|
<div class="text-right">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">Last updated</p>
|
<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>
|
<p id="last-refresh" class="text-sm text-gray-700 dark:text-gray-300 font-mono">--</p>
|
||||||
</div>
|
</div>
|
||||||
|
</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 -->
|
<!-- Dashboard cards with auto-refresh -->
|
||||||
<div hx-get="/api/status-snapshot"
|
<div hx-get="/api/status-snapshot"
|
||||||
hx-trigger="load, every 10s"
|
hx-trigger="load, every 10s"
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 %}
|
||||||
@@ -589,6 +589,20 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</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
|
{# Metadata Backfill + Project Tidy moved to Tools (they're
|
||||||
operator workflows, not admin/dev surfaces). Find them
|
operator workflows, not admin/dev surfaces). Find them
|
||||||
at /tools. #}
|
at /tools. #}
|
||||||
|
|||||||
@@ -14,6 +14,43 @@
|
|||||||
<!-- Card grid -->
|
<!-- Card grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<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 -->
|
<!-- Pair Devices -->
|
||||||
<a href="/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">
|
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">
|
||||||
|
|||||||
@@ -379,6 +379,20 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- Event table -->
|
||||||
<div id="ue-events-container" class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
<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">
|
<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>'
|
? '<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)}')">
|
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 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.tran_ppv)}">${tran}</td>
|
||||||
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.vert_ppv)}">${vert}</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">
|
<table class="w-full text-left">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
<tr>
|
<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('Timestamp', 'timestamp')}
|
||||||
${_ueSortableTh('Tran', 'tran_ppv')}
|
${_ueSortableTh('Tran', 'tran_ppv')}
|
||||||
${_ueSortableTh('Vert', 'vert_ppv')}
|
${_ueSortableTh('Vert', 'vert_ppv')}
|
||||||
@@ -2679,6 +2704,85 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||||
</table>`;
|
</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 =====
|
// ===== Pair Device Modal Functions =====
|
||||||
|
|||||||
Reference in New Issue
Block a user