Compare commits
54 Commits
v0.8.0
...
449e031589
| Author | SHA1 | Date | |
|---|---|---|---|
| 449e031589 | |||
| 18fd0472a5 | |||
| e15481884a | |||
| 737901c962 | |||
| 2cf5bf47d3 | |||
| 77483c2186 | |||
| b1c2a1d778 | |||
| d3b5a3fd26 | |||
| d46f9fccf8 | |||
| 6ebbe28308 | |||
| 42de06f441 | |||
| 21844b4d65 | |||
| 80fa76208a | |||
| f1f3da8e61 | |||
| 63bd6ad8a2 | |||
| bc5a151faa | |||
| 09db988a35 | |||
| df771a87de | |||
| a71e6f5efd | |||
| ec661ee079 | |||
| 63ba63edaf | |||
| 2ba20c7809 | |||
| f84d0818d2 | |||
| 3e0d20d62d | |||
| f50cf2b7f6 | |||
| 20e180644e | |||
| 73a6ff4d20 | |||
| 0f582a8a17 | |||
| 184f0ddd13 | |||
| e7bd09418b | |||
| 27eeb0fae6 | |||
| 192e15f238 | |||
| 49bc625c1a | |||
| 95fedca8c9 | |||
| e8e155556a | |||
| 33e962e73d | |||
| ac48fb2977 | |||
| 5e9cc32fdc | |||
| 3c4b81cf78 | |||
| d135727ebd | |||
| 64d4423308 | |||
| 4f71d528ce | |||
| 4f56dea4f3 | |||
| 40359db066 | |||
| 57a85f565b | |||
| e6555ba924 | |||
| 3d5b2fddef | |||
| 8694282dd0 | |||
| bc02dc9564 | |||
| 0d01715f81 | |||
| b3ec249c5e | |||
| b6e74258f1 | |||
| 0e3f512203 | |||
| e4d1f0d684 |
+124
@@ -5,6 +5,130 @@ All notable changes to Terra-View will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.9.4] - 2026-04-06
|
||||
|
||||
### Added
|
||||
- **Modular Project Types**: Projects now support optional modules (Sound Monitoring, Vibration Monitoring) selectable at creation time. The project header and dashboard dynamically show/hide tabs and actions based on which modules are enabled, and modules can be added or removed after creation.
|
||||
- **Deleted Project Management**: Settings page now includes a section for soft-deleted projects with options to restore or permanently delete each one. Deleted projects load automatically when the Data tab is opened.
|
||||
|
||||
### Changed
|
||||
- **Swap Modal Search**: The unit/modem swap modal on vibration location detail pages now includes live search filtering for both seismographs and modems, making it easier to find the right unit in large fleets.
|
||||
|
||||
### Fixed
|
||||
- **Roster Auto-Refresh No Longer Disrupts Scroll/Sort**: The roster page's 30-second background refresh now updates status, age, and last-seen values in-place via a lightweight JSON poll instead of replacing the entire table HTML. Sort order, scroll position, and active filters are all preserved across refreshes.
|
||||
|
||||
### Migration Notes
|
||||
Run on each database before deploying:
|
||||
```bash
|
||||
docker compose exec terra-view python3 backend/migrate_add_project_modules.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.9.3] - 2026-03-28
|
||||
|
||||
### Added
|
||||
- **Monitoring Session Detail Page**: New dedicated page for each session showing session info, data files (with View/Report/Download actions), an editable session panel, and report actions.
|
||||
- **Session Calendar with Gantt Bars**: Monthly calendar view below the session list, showing each session as a Gantt-style bar. The dim bar represents the full device on/off window; the bright bar highlights the effective recording window. Bars extend edge-to-edge across day cells for sessions spanning midnight.
|
||||
- **Configurable Period Windows**: Sessions now store `period_start_hour` and `period_end_hour` to define the exact hours that count toward reports, replacing hardcoded day/night defaults. The session edit panel shows a "Required Recording Window" section with a live preview (e.g. "7:00 AM → 7:00 PM") and a Defaults button that auto-fills based on period type.
|
||||
- **Report Date Field**: Sessions can now store an explicit `report_date` to override the automatic target-date heuristic — useful when a device ran across multiple days but only one specific day's data is needed for the report.
|
||||
- **Effective Window on Session Info**: Session detail and session cards now show an "Effective" row displaying the computed recording window dates and times in local time.
|
||||
- **Vibration Project Redesign**: Vibration project detail page is stripped back to project details and monitoring locations only. Each location supports assigning a seismograph and optional modem. Sound-specific tabs (Schedules, Sessions, Data Files, Assigned Units) are hidden for vibration projects.
|
||||
- **Modem Assignment on Locations**: Vibration monitoring locations now support an optional paired modem alongside the seismograph. The swap endpoint handles both assignments atomically, updating bidirectional pairing fields on both units.
|
||||
- **Available Modems Endpoint**: New `GET /api/projects/{project_id}/available-modems` endpoint returning all deployed, non-retired modems for use in assignment dropdowns.
|
||||
|
||||
### Fixed
|
||||
- **Active Assignment Checks**: Unified all `UnitAssignment` "active" checks from `status == "active"` to `assigned_until IS NULL` throughout `project_locations.py` and `projects.py` for consistency with the canonical active definition.
|
||||
|
||||
### Changed
|
||||
- **Sound-Only Endpoint Guards**: FTP browser, RND viewer, Excel report generation, combined report wizard, and data upload endpoints now return HTTP 400 if called on a non-sound-monitoring project.
|
||||
|
||||
### Migration Notes
|
||||
Run on each database before deploying:
|
||||
```bash
|
||||
docker compose exec terra-view python3 backend/migrate_add_session_period_hours.py
|
||||
docker compose exec terra-view python3 backend/migrate_add_session_report_date.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.9.2] - 2026-03-27
|
||||
|
||||
### Added
|
||||
- **Deployment Records**: Seismographs now track a full deployment history (location, project, dates). Each deployment is logged on the unit detail page with start/end dates, and the fleet calendar service uses this history for availability calculations.
|
||||
- **Allocated Unit Status**: New `allocated` status for units reserved for an upcoming job but not yet deployed. Allocated units appear in the dashboard summary, roster filters, and devices table with visual indicators.
|
||||
- **Project Allocation**: Units can be linked to a project via `allocated_to_project_id`. Allocation is shown on the unit detail page and in a new quick-info modal accessible from the fleet calendar and roster.
|
||||
- **Quick-Info Unit Modal**: Click any unit in the fleet calendar or roster to open a modal showing cal status, project allocation, upcoming jobs, and deployment state — without leaving the page.
|
||||
- **Cal Date in Planner**: When a unit is selected for a monitoring location slot in the Job Planner, its calibration expiry date is now shown inline so you can spot near-expiry units before committing.
|
||||
- **Inline Seismograph Editing**: Unit rows in the seismograph dashboard now support inline editing of cal date, notes, and deployment status without navigating to the full detail page.
|
||||
|
||||
### Migration Notes
|
||||
Run on each database before deploying:
|
||||
```bash
|
||||
docker compose exec terra-view python3 backend/migrate_add_allocated.py
|
||||
docker compose exec terra-view python3 backend/migrate_add_deployment_records.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.9.1] - 2026-03-20
|
||||
|
||||
### Fixed
|
||||
- **Location slots not persisting**: Empty monitoring location slots (no unit assigned yet) were lost on save/reload. Added `location_slots` JSON column to `job_reservations` to store the full slot list including empty slots.
|
||||
- **Modems in Recent Alerts**: Modems no longer appear in the dashboard Recent Alerts panel — alerts are for seismographs and SLMs only. Modem status is still tracked internally via paired device inheritance.
|
||||
- **Series 4 heartbeat `source_id`**: Updated heartbeat endpoint to accept the new `source_id` field from Series 4 units with fallback to the legacy field for backwards compatibility.
|
||||
|
||||
### Migration Notes
|
||||
Run on each database before deploying:
|
||||
```bash
|
||||
docker compose exec terra-view python3 backend/migrate_add_location_slots.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.9.0] - 2026-03-19
|
||||
|
||||
### Added
|
||||
- **Job Planner**: Full redesign of the Fleet Calendar into a two-tab Job Planner / Calendar interface
|
||||
- **Planner tab**: Create and manage job reservations with name, device type, dates, color, estimated units, and monitoring locations
|
||||
- **Calendar tab**: 12-month rolling heatmap with colored job bars per day; confirmed jobs solid, planned jobs dashed
|
||||
- **Monitoring Locations**: Each job has named location slots (filled = unit assigned, empty = needs a unit); progress shown as `2/5` with colored squares that fill as units are assigned
|
||||
- **Estimated Units**: Separate planning number independent of actual location count; shown prominently on job cards
|
||||
- **Fleet Summary panel**: Unit counts as clickable filter buttons; unit list shows reservation badges with job name, dates, and color
|
||||
- **Available Units panel**: Shows units available for the job's date range when assigning
|
||||
- **Smart color picker**: 18-swatch palette + custom color wheel; new jobs auto-pick a color maximally distant in hue from existing jobs
|
||||
- **Job card progress**: `est. N · X/Y (Z more)` with filled/empty squares; amber → green when fully assigned
|
||||
- **Promote to Project**: Promote a planned job to a tracked project directly from the planner form
|
||||
- **Collapsible job details**: Name, dates, device type, color, project link, and estimated units collapse into a summary header
|
||||
- **Calendar bar tooltips**: Hover any job bar to see job name and date range
|
||||
- **Hash-based tab persistence**: `#cal` in URL restores Calendar tab on refresh; device type toggle preserves active tab
|
||||
- **Auto-scroll to today**: Switching to Calendar tab smooth-scrolls to the current month
|
||||
- **Upcoming project status**: New `upcoming` status for projects promoted from reservations
|
||||
- **Job device type**: Reservations carry a device type so they only appear on the correct calendar
|
||||
- **Project filtering by device type**: Projects only appear on the calendar matching their type (vibration → seismograph, sound → SLM, combined → both)
|
||||
- **Confirmed/Planned toggles**: Independent show/hide toggles for job bar layers on the calendar
|
||||
- **Cal expire dots toggle**: Calibration expiry dots off by default, togglable
|
||||
|
||||
### Changed
|
||||
- **Renamed**: "Fleet Calendar" / "Reservation Planner" → **"Job Planner"** throughout UI and sidebar
|
||||
- **Project status dropdown**: Inline `<select>` in project header for quick status changes
|
||||
- **"All Projects" tab**: Shows everything except deleted; default view excludes archived/completed
|
||||
- **Toast notifications**: All `alert()` dialogs replaced with non-blocking toasts (green = success, red = error)
|
||||
|
||||
### Migration Notes
|
||||
Run on each database before deploying:
|
||||
```bash
|
||||
docker compose exec terra-view python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('/app/data/seismo_fleet.db')
|
||||
conn.execute('ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.8.0] - 2026-03-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Terra-View v0.8.0
|
||||
# Terra-View v0.9.4
|
||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||
|
||||
## Features
|
||||
|
||||
+77
-7
@@ -18,7 +18,7 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from backend.database import engine, Base, get_db
|
||||
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard
|
||||
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, sfm, projects, project_locations, scheduler, modem_dashboard
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
from backend.models import IgnoredUnit
|
||||
from backend.utils.timezone import get_user_timezone
|
||||
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
|
||||
# Initialize FastAPI app
|
||||
VERSION = "0.8.0"
|
||||
VERSION = "0.9.4"
|
||||
if ENVIRONMENT == "development":
|
||||
_build = os.getenv("BUILD_NUMBER", "0")
|
||||
if _build and _build != "0":
|
||||
@@ -97,6 +97,7 @@ app.include_router(slmm.router)
|
||||
app.include_router(slm_ui.router)
|
||||
app.include_router(slm_dashboard.router)
|
||||
app.include_router(seismo_dashboard.router)
|
||||
app.include_router(sfm.router)
|
||||
app.include_router(modem_dashboard.router)
|
||||
|
||||
from backend.routers import settings
|
||||
@@ -114,6 +115,10 @@ app.include_router(scheduler.router)
|
||||
from backend.routers import report_templates
|
||||
app.include_router(report_templates.router)
|
||||
|
||||
# Metadata-backfill admin router (Phase 5a)
|
||||
from backend.routers import metadata_backfill
|
||||
app.include_router(metadata_backfill.router)
|
||||
|
||||
# Alerts router
|
||||
from backend.routers import alerts
|
||||
app.include_router(alerts.router)
|
||||
@@ -126,6 +131,10 @@ app.include_router(recurring_schedules.router)
|
||||
from backend.routers import fleet_calendar
|
||||
app.include_router(fleet_calendar.router)
|
||||
|
||||
# Deployment Records router
|
||||
from backend.routers import deployments
|
||||
app.include_router(deployments.router)
|
||||
|
||||
# Start scheduler service and device status monitor on application startup
|
||||
from backend.services.scheduler import start_scheduler, stop_scheduler
|
||||
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
||||
@@ -229,6 +238,34 @@ async def seismographs_page(request: Request):
|
||||
return templates.TemplateResponse("seismographs.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/sfm", response_class=HTMLResponse)
|
||||
async def sfm_page(request: Request):
|
||||
"""SFM live event data and device control dashboard"""
|
||||
return templates.TemplateResponse("sfm.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/settings/developer/metadata-backfill", response_class=HTMLResponse)
|
||||
async def metadata_backfill_wizard_page(request: Request):
|
||||
"""Wizard for auto-creating projects/locations/assignments from
|
||||
operator-typed BW event metadata (Phase 5a)."""
|
||||
return templates.TemplateResponse("admin/metadata_backfill.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/settings/developer/project-tidy", response_class=HTMLResponse)
|
||||
async def project_tidy_page(request: Request):
|
||||
"""Tidy duplicate-looking projects: detect by fuzzy name match, merge
|
||||
by clicking through pairs (Phase 5b)."""
|
||||
return templates.TemplateResponse("admin/project_tidy.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/tools", response_class=HTMLResponse)
|
||||
async def tools_page(request: Request):
|
||||
"""Tools / workflow hub. Active operator workflows (device pairing,
|
||||
project tidy, metadata backfill, future swap detection, report
|
||||
generators) all live here in card form."""
|
||||
return templates.TemplateResponse("tools.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/modems", response_class=HTMLResponse)
|
||||
async def modems_page(request: Request):
|
||||
"""Field modems management dashboard"""
|
||||
@@ -351,8 +388,11 @@ async def nrl_detail_page(
|
||||
).first()
|
||||
|
||||
assigned_unit = None
|
||||
assigned_modem = None
|
||||
if assignment:
|
||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||
if assigned_unit and assigned_unit.deployed_with_modem_id:
|
||||
assigned_modem = db.query(RosterUnit).filter_by(id=assigned_unit.deployed_with_modem_id).first()
|
||||
|
||||
# Get session count
|
||||
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
|
||||
@@ -389,6 +429,7 @@ async def nrl_detail_page(
|
||||
"location": location,
|
||||
"assignment": assignment,
|
||||
"assigned_unit": assigned_unit,
|
||||
"assigned_modem": assigned_modem,
|
||||
"session_count": session_count,
|
||||
"file_count": file_count,
|
||||
"active_session": active_session,
|
||||
@@ -703,6 +744,33 @@ async def devices_all_partial(request: Request):
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
})
|
||||
|
||||
# Add allocated units
|
||||
for unit_id, unit_data in snapshot.get("allocated", {}).items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": "Allocated",
|
||||
"age": "N/A",
|
||||
"last_seen": "N/A",
|
||||
"deployed": False,
|
||||
"retired": False,
|
||||
"out_for_calibration": False,
|
||||
"allocated": True,
|
||||
"allocated_to_project_id": unit_data.get("allocated_to_project_id", ""),
|
||||
"ignored": False,
|
||||
"note": unit_data.get("note", ""),
|
||||
"device_type": unit_data.get("device_type", "seismograph"),
|
||||
"address": unit_data.get("address", ""),
|
||||
"coordinates": unit_data.get("coordinates", ""),
|
||||
"project_id": unit_data.get("project_id", ""),
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
})
|
||||
|
||||
# Add out-for-calibration units
|
||||
for unit_id, unit_data in snapshot["out_for_calibration"].items():
|
||||
units_list.append({
|
||||
@@ -780,17 +848,19 @@ async def devices_all_partial(request: Request):
|
||||
|
||||
# Sort by status category, then by ID
|
||||
def sort_key(unit):
|
||||
# Priority: deployed (active) -> benched -> out_for_calibration -> retired -> ignored
|
||||
# Priority: deployed (active) -> allocated -> benched -> out_for_calibration -> retired -> ignored
|
||||
if unit["deployed"]:
|
||||
return (0, unit["id"])
|
||||
elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]:
|
||||
elif unit.get("allocated"):
|
||||
return (1, unit["id"])
|
||||
elif unit["out_for_calibration"]:
|
||||
elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]:
|
||||
return (2, unit["id"])
|
||||
elif unit["retired"]:
|
||||
elif unit["out_for_calibration"]:
|
||||
return (3, unit["id"])
|
||||
else:
|
||||
elif unit["retired"]:
|
||||
return (4, unit["id"])
|
||||
else:
|
||||
return (5, unit["id"])
|
||||
|
||||
units_list.sort(key=sort_key)
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Migration: Add allocated and allocated_to_project_id columns to roster table.
|
||||
Run once: python backend/migrate_add_allocated.py
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'seismo_fleet.db')
|
||||
|
||||
def run():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Check existing columns
|
||||
cur.execute("PRAGMA table_info(roster)")
|
||||
cols = {row[1] for row in cur.fetchall()}
|
||||
|
||||
if 'allocated' not in cols:
|
||||
cur.execute("ALTER TABLE roster ADD COLUMN allocated BOOLEAN DEFAULT 0 NOT NULL")
|
||||
print("Added column: allocated")
|
||||
else:
|
||||
print("Column already exists: allocated")
|
||||
|
||||
if 'allocated_to_project_id' not in cols:
|
||||
cur.execute("ALTER TABLE roster ADD COLUMN allocated_to_project_id VARCHAR")
|
||||
print("Added column: allocated_to_project_id")
|
||||
else:
|
||||
print("Column already exists: allocated_to_project_id")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("Migration complete.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Migration: Add deployment_records table.
|
||||
|
||||
Tracks each time a unit is sent to the field and returned.
|
||||
The active deployment is the row with actual_removal_date IS NULL.
|
||||
|
||||
Run once per database:
|
||||
python backend/migrate_add_deployment_records.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = "./data/seismo_fleet.db"
|
||||
|
||||
|
||||
def migrate_database():
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Database not found at {DB_PATH}")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Check if table already exists
|
||||
cursor.execute("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='deployment_records'
|
||||
""")
|
||||
if cursor.fetchone():
|
||||
print("✓ deployment_records table already exists, skipping")
|
||||
return
|
||||
|
||||
print("Creating deployment_records table...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE deployment_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
unit_id TEXT NOT NULL,
|
||||
deployed_date DATE,
|
||||
estimated_removal_date DATE,
|
||||
actual_removal_date DATE,
|
||||
project_ref TEXT,
|
||||
project_id TEXT,
|
||||
location_name TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX idx_deployment_records_unit_id
|
||||
ON deployment_records(unit_id)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE INDEX idx_deployment_records_project_id
|
||||
ON deployment_records(project_id)
|
||||
""")
|
||||
# Index for finding active deployments quickly
|
||||
cursor.execute("""
|
||||
CREATE INDEX idx_deployment_records_active
|
||||
ON deployment_records(unit_id, actual_removal_date)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
print("✓ deployment_records table created successfully")
|
||||
print("✓ Indexes created")
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"✗ Migration failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_database()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Migration: Add estimated_units to job_reservations
|
||||
|
||||
Adds column:
|
||||
- job_reservations.estimated_units: Estimated number of units for the reservation (nullable integer)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Default database path (matches production pattern)
|
||||
DB_PATH = "./data/seismo_fleet.db"
|
||||
|
||||
|
||||
def migrate(db_path: str):
|
||||
"""Run the migration."""
|
||||
print(f"Migrating database: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Check if job_reservations table exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
|
||||
if not cursor.fetchone():
|
||||
print("job_reservations table does not exist. Skipping migration.")
|
||||
return
|
||||
|
||||
# Get existing columns in job_reservations
|
||||
cursor.execute("PRAGMA table_info(job_reservations)")
|
||||
existing_cols = {row[1] for row in cursor.fetchall()}
|
||||
|
||||
# Add estimated_units column if it doesn't exist
|
||||
if 'estimated_units' not in existing_cols:
|
||||
print("Adding estimated_units column to job_reservations...")
|
||||
cursor.execute("ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER")
|
||||
else:
|
||||
print("estimated_units column already exists. Skipping.")
|
||||
|
||||
conn.commit()
|
||||
print("Migration completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Migration failed: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
db_path = DB_PATH
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
db_path = sys.argv[1]
|
||||
|
||||
if not Path(db_path).exists():
|
||||
print(f"Database not found: {db_path}")
|
||||
sys.exit(1)
|
||||
|
||||
migrate(db_path)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Migration: Add location_slots column to job_reservations table.
|
||||
Stores the full ordered slot list (including empty/unassigned slots) as JSON.
|
||||
Run once per database.
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", "/app/data/seismo_fleet.db")
|
||||
|
||||
def run():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
existing = [r[1] for r in cursor.execute("PRAGMA table_info(job_reservations)").fetchall()]
|
||||
if "location_slots" not in existing:
|
||||
cursor.execute("ALTER TABLE job_reservations ADD COLUMN location_slots TEXT")
|
||||
conn.commit()
|
||||
print("Added location_slots column to job_reservations.")
|
||||
else:
|
||||
print("location_slots column already exists, skipping.")
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Migration: add metadata-backfill support.
|
||||
|
||||
Adds:
|
||||
1. `unit_assignments.source` column (TEXT, default 'manual').
|
||||
Lets us audit which assignments were created by the metadata-backfill
|
||||
parser vs by a human, and bulk-undo parser actions if needed.
|
||||
|
||||
2. `metadata_backfill_decisions` table. Tracks operator decisions per
|
||||
cluster_id so the wizard remembers what's been skipped, what's
|
||||
been applied, and what's pending across re-scans.
|
||||
|
||||
Idempotent — safe to re-run.
|
||||
Non-destructive — adds only.
|
||||
|
||||
Run with:
|
||||
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_metadata_backfill.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
DB_PATH = "./data/seismo_fleet.db"
|
||||
|
||||
|
||||
def migrate_database():
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Database not found at {DB_PATH}")
|
||||
return
|
||||
|
||||
print(f"Migrating database: {DB_PATH}")
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
|
||||
# ── 1. unit_assignments.source column ──────────────────────────────────
|
||||
cur.execute("PRAGMA table_info(unit_assignments)")
|
||||
cols = {row[1] for row in cur.fetchall()}
|
||||
if "source" not in cols:
|
||||
print("Adding unit_assignments.source column (default 'manual') ...")
|
||||
cur.execute(
|
||||
"ALTER TABLE unit_assignments ADD COLUMN source TEXT DEFAULT 'manual'"
|
||||
)
|
||||
# Backfill: any existing row gets source='manual'
|
||||
cur.execute("UPDATE unit_assignments SET source='manual' WHERE source IS NULL")
|
||||
conn.commit()
|
||||
print(" Done.")
|
||||
else:
|
||||
print("unit_assignments.source already exists — skipping")
|
||||
|
||||
# ── 2. metadata_backfill_decisions table ──────────────────────────────
|
||||
cur.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='metadata_backfill_decisions'"
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
print("Creating metadata_backfill_decisions table ...")
|
||||
cur.execute("""
|
||||
CREATE TABLE metadata_backfill_decisions (
|
||||
cluster_id TEXT PRIMARY KEY, -- deterministic hash
|
||||
status TEXT NOT NULL, -- pending | applied | skipped | conflict
|
||||
confidence TEXT NOT NULL, -- high | medium | low (at time of decision)
|
||||
decided_at TEXT, -- when applied/skipped
|
||||
decided_by TEXT, -- 'background' | 'operator' | 'auto-high'
|
||||
applied_assignment_id TEXT, -- FK to unit_assignments (if applied)
|
||||
notes TEXT,
|
||||
first_seen_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
serial TEXT NOT NULL,
|
||||
project_raw TEXT,
|
||||
location_raw TEXT,
|
||||
first_event_ts TEXT,
|
||||
last_event_ts TEXT,
|
||||
event_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
cur.execute(
|
||||
"CREATE INDEX idx_mbd_status ON metadata_backfill_decisions(status)"
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE INDEX idx_mbd_last_seen ON metadata_backfill_decisions(last_seen_at)"
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE INDEX idx_mbd_serial ON metadata_backfill_decisions(serial)"
|
||||
)
|
||||
conn.commit()
|
||||
print(" Done.")
|
||||
else:
|
||||
print("metadata_backfill_decisions table already exists — skipping")
|
||||
|
||||
conn.close()
|
||||
print("\nMigration complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_database()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Migration: Add project_modules table and seed from existing project_type_id values.
|
||||
|
||||
Safe to run multiple times — idempotent.
|
||||
"""
|
||||
import sqlite3
|
||||
import uuid
|
||||
import os
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "seismo_fleet.db")
|
||||
DB_PATH = os.path.abspath(DB_PATH)
|
||||
|
||||
|
||||
def run():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
# 1. Create project_modules table if not exists
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS project_modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
module_type TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(project_id, module_type)
|
||||
)
|
||||
""")
|
||||
print(" Table 'project_modules' ready.")
|
||||
|
||||
# 2. Seed modules from existing project_type_id values
|
||||
cur.execute("SELECT id, project_type_id FROM projects WHERE project_type_id IS NOT NULL")
|
||||
projects = cur.fetchall()
|
||||
|
||||
seeded = 0
|
||||
for p in projects:
|
||||
pid = p["id"]
|
||||
ptype = p["project_type_id"]
|
||||
|
||||
modules_to_add = []
|
||||
if ptype == "sound_monitoring":
|
||||
modules_to_add = ["sound_monitoring"]
|
||||
elif ptype == "vibration_monitoring":
|
||||
modules_to_add = ["vibration_monitoring"]
|
||||
elif ptype == "combined":
|
||||
modules_to_add = ["sound_monitoring", "vibration_monitoring"]
|
||||
|
||||
for module_type in modules_to_add:
|
||||
# INSERT OR IGNORE — skip if already exists
|
||||
cur.execute("""
|
||||
INSERT OR IGNORE INTO project_modules (id, project_id, module_type, enabled)
|
||||
VALUES (?, ?, ?, 1)
|
||||
""", (str(uuid.uuid4()), pid, module_type))
|
||||
if cur.rowcount > 0:
|
||||
seeded += 1
|
||||
|
||||
conn.commit()
|
||||
print(f" Seeded {seeded} module record(s) from existing project_type_id values.")
|
||||
|
||||
# 3. Make project_type_id nullable (SQLite doesn't support ALTER COLUMN,
|
||||
# but since we're just loosening a constraint this is a no-op in SQLite —
|
||||
# the column already accepts NULL in practice. Nothing to do.)
|
||||
print(" project_type_id column is now treated as nullable (legacy field).")
|
||||
|
||||
conn.close()
|
||||
print("Migration complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Migration: add period_start_hour and period_end_hour to monitoring_sessions.
|
||||
|
||||
Run once:
|
||||
python backend/migrate_add_session_period_hours.py
|
||||
|
||||
Or inside the container:
|
||||
docker exec terra-view python3 backend/migrate_add_session_period_hours.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from backend.database import engine
|
||||
from sqlalchemy import text
|
||||
|
||||
def run():
|
||||
with engine.connect() as conn:
|
||||
# Check which columns already exist
|
||||
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
|
||||
existing = {row[1] for row in result}
|
||||
|
||||
added = []
|
||||
for col, definition in [
|
||||
("period_start_hour", "INTEGER"),
|
||||
("period_end_hour", "INTEGER"),
|
||||
]:
|
||||
if col not in existing:
|
||||
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
|
||||
added.append(col)
|
||||
else:
|
||||
print(f" Column '{col}' already exists — skipping.")
|
||||
|
||||
conn.commit()
|
||||
|
||||
if added:
|
||||
print(f" Added columns: {', '.join(added)}")
|
||||
print("Migration complete.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Migration: add report_date to monitoring_sessions.
|
||||
|
||||
Run once:
|
||||
python backend/migrate_add_session_report_date.py
|
||||
|
||||
Or inside the container:
|
||||
docker exec terra-view-terra-view-1 python3 backend/migrate_add_session_report_date.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from backend.database import engine
|
||||
from sqlalchemy import text
|
||||
|
||||
def run():
|
||||
with engine.connect() as conn:
|
||||
# Check which columns already exist
|
||||
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
|
||||
existing = {row[1] for row in result}
|
||||
|
||||
added = []
|
||||
for col, definition in [
|
||||
("report_date", "DATE"),
|
||||
]:
|
||||
if col not in existing:
|
||||
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
|
||||
added.append(col)
|
||||
else:
|
||||
print(f" Column '{col}' already exists — skipping.")
|
||||
|
||||
conn.commit()
|
||||
|
||||
if added:
|
||||
print(f" Added columns: {', '.join(added)}")
|
||||
print("Migration complete.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Migration: deprecate the `deployment_records` table.
|
||||
|
||||
Why:
|
||||
The deployment-history view on the unit detail page used to render
|
||||
from `deployment_records` — a manually-maintained table that drifted
|
||||
out of sync with `unit_assignments` (the auto-written project/location
|
||||
assignment table). That caused the "wonky timeline" symptom: missing
|
||||
entries, duplicate / contradictory rows, and a UI that couldn't tell
|
||||
the operator what the unit was actually doing during each window.
|
||||
|
||||
Phase 4 of the SFM integration replaces the deployment-history view
|
||||
with a derived timeline computed from `unit_assignments` +
|
||||
`unit_history` + SFM event overlay. This migration is the cleanup:
|
||||
|
||||
1. Adds a `deprecated_at` timestamp column to `deployment_records` so
|
||||
we can mark rows that have been migrated.
|
||||
2. For every `deployment_records` row that does NOT have a matching
|
||||
`unit_assignments` row (matched by unit_id + overlapping date
|
||||
range), synthesizes a best-effort UnitAssignment row. The
|
||||
free-text `location_name` from the legacy table is preserved on
|
||||
the new row's `notes` field (we do NOT try to fuzzy-match it to a
|
||||
MonitoringLocation id; too error-prone — operators will need to
|
||||
reattach those manually if they want).
|
||||
3. Marks every migrated deployment_records row with `deprecated_at`.
|
||||
|
||||
This migration is non-destructive: deployment_records rows stay in
|
||||
the DB. The actual `DROP TABLE` happens in a follow-up release after
|
||||
one operator cycle confirms nothing relies on the legacy data.
|
||||
|
||||
Idempotent: re-running the script is a no-op if the column already
|
||||
exists and all migratable rows have already been processed.
|
||||
|
||||
Run with:
|
||||
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_deprecate_deployment_records.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
DB_PATH = "./data/seismo_fleet.db"
|
||||
|
||||
|
||||
def migrate_database():
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Database not found at {DB_PATH}")
|
||||
return
|
||||
|
||||
print(f"Migrating database: {DB_PATH}")
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
# 1. Add deprecated_at column if not present.
|
||||
cur.execute("PRAGMA table_info(deployment_records)")
|
||||
cols = {row["name"] for row in cur.fetchall()}
|
||||
if "deprecated_at" not in cols:
|
||||
print("Adding deployment_records.deprecated_at column ...")
|
||||
cur.execute("ALTER TABLE deployment_records ADD COLUMN deprecated_at TEXT")
|
||||
conn.commit()
|
||||
else:
|
||||
print("deployment_records.deprecated_at column already exists — skipping ADD COLUMN")
|
||||
|
||||
# 2. Find candidate rows: not-yet-deprecated deployment_records that
|
||||
# have no matching unit_assignments row.
|
||||
cur.execute("""
|
||||
SELECT id, unit_id, deployed_date, estimated_removal_date,
|
||||
actual_removal_date, project_id, project_ref, location_name, notes
|
||||
FROM deployment_records
|
||||
WHERE deprecated_at IS NULL
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
print(f"\nFound {len(rows)} deployment_records rows not yet deprecated.")
|
||||
|
||||
backfilled = 0
|
||||
skipped_no_match_attempted = 0
|
||||
skipped_already_in_assignments = 0
|
||||
skipped_missing_unit = 0
|
||||
|
||||
for row in rows:
|
||||
unit_id = row["unit_id"]
|
||||
if not unit_id:
|
||||
print(f" ⚠ row {row['id']!r}: no unit_id, marking deprecated without backfill")
|
||||
cur.execute(
|
||||
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), row["id"]),
|
||||
)
|
||||
skipped_missing_unit += 1
|
||||
continue
|
||||
|
||||
# Does the unit still exist? If not, skip — we don't synthesize
|
||||
# assignments for ghost units.
|
||||
cur.execute("SELECT id, device_type FROM roster WHERE id=?", (unit_id,))
|
||||
roster = cur.fetchone()
|
||||
if not roster:
|
||||
print(f" ⚠ row {row['id']!r}: unit_id {unit_id!r} not in roster, marking deprecated without backfill")
|
||||
cur.execute(
|
||||
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), row["id"]),
|
||||
)
|
||||
skipped_missing_unit += 1
|
||||
continue
|
||||
|
||||
# Check if a UnitAssignment already covers this window (any overlap).
|
||||
# We don't try to be clever — just see if a row exists for this unit
|
||||
# whose [assigned_at, assigned_until] overlaps the deployment window.
|
||||
cur.execute("""
|
||||
SELECT id FROM unit_assignments
|
||||
WHERE unit_id=?
|
||||
AND (assigned_at <= COALESCE(?, '9999')
|
||||
AND COALESCE(assigned_until, '9999') >= COALESCE(?, '0000'))
|
||||
LIMIT 1
|
||||
""", (
|
||||
unit_id,
|
||||
row["actual_removal_date"] or row["estimated_removal_date"] or row["deployed_date"],
|
||||
row["deployed_date"],
|
||||
))
|
||||
if cur.fetchone():
|
||||
cur.execute(
|
||||
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), row["id"]),
|
||||
)
|
||||
skipped_already_in_assignments += 1
|
||||
continue
|
||||
|
||||
# No matching UnitAssignment — synthesize one. We can't FK to a
|
||||
# MonitoringLocation because the legacy `location_name` is free
|
||||
# text. Backfilled rows go in with location_id = "" (empty) and
|
||||
# the original location_name dropped into notes for operator
|
||||
# context.
|
||||
if not row["project_id"]:
|
||||
print(f" ⚠ row {row['id']!r}: no project_id, can't synthesize unit_assignment, marking deprecated")
|
||||
cur.execute(
|
||||
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), row["id"]),
|
||||
)
|
||||
skipped_no_match_attempted += 1
|
||||
continue
|
||||
|
||||
synthesized_id = str(uuid.uuid4())
|
||||
synth_notes_parts = []
|
||||
if row["location_name"]:
|
||||
synth_notes_parts.append(f"Legacy location: {row['location_name']}")
|
||||
if row["project_ref"]:
|
||||
synth_notes_parts.append(f"Legacy project_ref: {row['project_ref']}")
|
||||
if row["notes"]:
|
||||
synth_notes_parts.append(f"Original notes: {row['notes']}")
|
||||
synth_notes_parts.append(f"(Synthesized from deployment_records row {row['id']})")
|
||||
synth_notes = " | ".join(synth_notes_parts)
|
||||
|
||||
assigned_until = row["actual_removal_date"]
|
||||
# Don't auto-close active deployments based on estimated_removal_date.
|
||||
status = "completed" if assigned_until else "active"
|
||||
|
||||
# Need a location_id to satisfy NOT NULL constraint. Use a
|
||||
# placeholder UUID so the FK can be cleaned up later if the
|
||||
# operator decides to retarget the assignment to a real location.
|
||||
# We tag this with the synthesized notes so it's discoverable.
|
||||
placeholder_loc_id = ""
|
||||
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO unit_assignments (
|
||||
id, unit_id, location_id, project_id, device_type,
|
||||
assigned_at, assigned_until, status, notes, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
synthesized_id,
|
||||
unit_id,
|
||||
placeholder_loc_id,
|
||||
row["project_id"],
|
||||
roster["device_type"] or "seismograph",
|
||||
row["deployed_date"] or datetime.utcnow().isoformat(),
|
||||
assigned_until,
|
||||
status,
|
||||
synth_notes,
|
||||
datetime.utcnow().isoformat(),
|
||||
))
|
||||
cur.execute(
|
||||
"UPDATE deployment_records SET deprecated_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), row["id"]),
|
||||
)
|
||||
backfilled += 1
|
||||
print(
|
||||
f" ✓ row {row['id']!r}: synthesized unit_assignment {synthesized_id} "
|
||||
f"for unit={unit_id} project={row['project_id'][:8]}… "
|
||||
f"({row['deployed_date']} → {assigned_until or 'present'})"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" ✗ row {row['id']!r}: failed to synthesize — {e}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print("\n────────────────────────────────────────────────────────")
|
||||
print(f"Backfilled new unit_assignments: {backfilled}")
|
||||
print(f"Already covered (deprecated only): {skipped_already_in_assignments}")
|
||||
print(f"No project_id (deprecated only): {skipped_no_match_attempted}")
|
||||
print(f"Missing/orphaned unit (deprecated): {skipped_missing_unit}")
|
||||
print(f"\nNOTE: synthesized rows have an empty location_id and the legacy")
|
||||
print(f" free-text location is preserved in notes. An operator should")
|
||||
print(f" retarget them to real MonitoringLocation rows if they want")
|
||||
print(f" events to show up on a location detail page.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_database()
|
||||
+117
-2
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
|
||||
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer, UniqueConstraint
|
||||
from datetime import datetime
|
||||
from backend.database import Base
|
||||
|
||||
@@ -33,6 +33,8 @@ class RosterUnit(Base):
|
||||
deployed = Column(Boolean, default=True)
|
||||
retired = Column(Boolean, default=False)
|
||||
out_for_calibration = Column(Boolean, default=False)
|
||||
allocated = Column(Boolean, default=False) # Staged for an upcoming job, not yet deployed
|
||||
allocated_to_project_id = Column(String, nullable=True) # Which project it's allocated to
|
||||
note = Column(String, nullable=True)
|
||||
project_id = Column(String, nullable=True)
|
||||
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
||||
@@ -175,7 +177,7 @@ class Project(Base):
|
||||
project_number = Column(String, nullable=True, index=True) # TMI ID: xxxx-YY format (e.g., "2567-23")
|
||||
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
||||
description = Column(Text, nullable=True)
|
||||
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
||||
project_type_id = Column(String, nullable=True) # Legacy FK to ProjectType.id; use ProjectModule for feature flags
|
||||
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
|
||||
|
||||
# Data collection mode: how field data reaches Terra-View.
|
||||
@@ -195,6 +197,22 @@ class Project(Base):
|
||||
deleted_at = Column(DateTime, nullable=True) # Set when status='deleted'; hard delete scheduled after 60 days
|
||||
|
||||
|
||||
class ProjectModule(Base):
|
||||
"""
|
||||
Modules enabled on a project. Each module unlocks a set of features/tabs.
|
||||
A project can have zero or more modules (sound_monitoring, vibration_monitoring, etc.).
|
||||
"""
|
||||
__tablename__ = "project_modules"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: __import__('uuid').uuid4().__str__())
|
||||
project_id = Column(String, nullable=False, index=True) # FK to projects.id
|
||||
module_type = Column(String, nullable=False) # sound_monitoring | vibration_monitoring | ...
|
||||
enabled = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
|
||||
|
||||
|
||||
class MonitoringLocation(Base):
|
||||
"""
|
||||
Monitoring locations: generic location for monitoring activities.
|
||||
@@ -241,9 +259,48 @@ class UnitAssignment(Base):
|
||||
device_type = Column(String, nullable=False) # "slm" | "seismograph"
|
||||
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
||||
|
||||
# Provenance: how was this assignment created? Used for auditing,
|
||||
# bulk-undo of parser actions, and the Phase 4 deployment timeline.
|
||||
# "manual" — operator created via UI
|
||||
# "metadata_backfill" — auto-created by the metadata parser
|
||||
# from operator-typed BW event metadata
|
||||
# (bulk backfill workflow)
|
||||
# "metadata_backfill_swap" — auto-created by swap-detection
|
||||
# background job
|
||||
source = Column(String, nullable=False, default="manual")
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class MetadataBackfillDecision(Base):
|
||||
"""
|
||||
Per-cluster decisions tracked by the metadata-backfill parser.
|
||||
|
||||
`cluster_id` is the deterministic SHA1 hash of
|
||||
(serial, first_event_date, last_event_date), so the same cluster
|
||||
produces the same id across re-scans. The decisions table lets the
|
||||
parser remember "I already applied this" or "operator skipped this"
|
||||
across scan invocations.
|
||||
"""
|
||||
__tablename__ = "metadata_backfill_decisions"
|
||||
|
||||
cluster_id = Column(String, primary_key=True)
|
||||
status = Column(String, nullable=False) # pending | applied | skipped | conflict
|
||||
confidence = Column(String, nullable=False) # high | medium | low
|
||||
decided_at = Column(DateTime, nullable=True)
|
||||
decided_by = Column(String, nullable=True) # background | operator | auto-high
|
||||
applied_assignment_id = Column(String, nullable=True) # FK to unit_assignments.id
|
||||
notes = Column(Text, nullable=True)
|
||||
first_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
last_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
serial = Column(String, nullable=False, index=True)
|
||||
project_raw = Column(String, nullable=True)
|
||||
location_raw = Column(String, nullable=True)
|
||||
first_event_ts = Column(DateTime, nullable=True)
|
||||
last_event_ts = Column(DateTime, nullable=True)
|
||||
event_count = Column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
class ScheduledAction(Base):
|
||||
"""
|
||||
Scheduled actions: automation for recording start/stop/download.
|
||||
@@ -301,6 +358,17 @@ class MonitoringSession(Base):
|
||||
# weekday_day | weekday_night | weekend_day | weekend_night
|
||||
period_type = Column(String, nullable=True)
|
||||
|
||||
# Effective monitoring window (hours 0–23). Night sessions cross midnight
|
||||
# (period_end_hour < period_start_hour). NULL = no filtering applied.
|
||||
# e.g. Day: start=7, end=19 Night: start=19, end=7
|
||||
period_start_hour = Column(Integer, nullable=True)
|
||||
period_end_hour = Column(Integer, nullable=True)
|
||||
|
||||
# For day sessions: the specific calendar date to use for report filtering.
|
||||
# Overrides the automatic "last date with daytime rows" heuristic.
|
||||
# Null = use heuristic.
|
||||
report_date = Column(Date, nullable=True)
|
||||
|
||||
# Snapshot of device configuration at recording time
|
||||
session_metadata = Column(Text, nullable=True) # JSON
|
||||
|
||||
@@ -448,6 +516,41 @@ class Alert(Base):
|
||||
expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Deployment Records
|
||||
# ============================================================================
|
||||
|
||||
class DeploymentRecord(Base):
|
||||
"""
|
||||
Deployment records: tracks each time a unit is sent to the field and returned.
|
||||
|
||||
Each row represents one deployment. The active deployment is the record
|
||||
with actual_removal_date IS NULL. The fleet calendar uses this to show
|
||||
units as "In Field" and surface their expected return date.
|
||||
|
||||
project_ref is a freeform string for legacy/vibration jobs like "Fay I-80".
|
||||
project_id will be populated once those jobs are migrated to proper Project records.
|
||||
"""
|
||||
__tablename__ = "deployment_records"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
||||
|
||||
deployed_date = Column(Date, nullable=True) # When unit left the yard
|
||||
estimated_removal_date = Column(Date, nullable=True) # Expected return date
|
||||
actual_removal_date = Column(Date, nullable=True) # Filled in when returned; NULL = still out
|
||||
|
||||
# Project linkage: freeform for legacy jobs, FK for proper project records
|
||||
project_ref = Column(String, nullable=True) # e.g. "Fay I-80" (vibration jobs)
|
||||
project_id = Column(String, nullable=True, index=True) # FK to Project.id (when available)
|
||||
|
||||
location_name = Column(String, nullable=True) # e.g. "North Gate", "VP-001"
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fleet Calendar & Job Reservations
|
||||
# ============================================================================
|
||||
@@ -480,6 +583,11 @@ class JobReservation(Base):
|
||||
# For quantity reservations
|
||||
device_type = Column(String, default="seismograph") # seismograph | slm
|
||||
quantity_needed = Column(Integer, nullable=True) # e.g., 8 units
|
||||
estimated_units = Column(Integer, nullable=True)
|
||||
|
||||
# Full slot list as JSON: [{"location_name": "North Gate", "unit_id": null}, ...]
|
||||
# Includes empty slots (no unit assigned yet). Filled slots are authoritative in JobReservationUnit.
|
||||
location_slots = Column(Text, nullable=True)
|
||||
|
||||
# Metadata
|
||||
notes = Column(Text, nullable=True)
|
||||
@@ -515,3 +623,10 @@ class JobReservationUnit(Base):
|
||||
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
|
||||
assigned_at = Column(DateTime, default=datetime.utcnow)
|
||||
notes = Column(Text, nullable=True) # "Replacing BE17353" etc.
|
||||
|
||||
# Power requirements for this deployment slot
|
||||
power_type = Column(String, nullable=True) # "ac" | "solar" | None
|
||||
|
||||
# Location identity
|
||||
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
|
||||
slot_index = Column(Integer, nullable=True) # Order within reservation (0-based)
|
||||
|
||||
@@ -4,11 +4,29 @@ from sqlalchemy import desc
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Dict, Any
|
||||
import os
|
||||
import logging
|
||||
import httpx
|
||||
from backend.database import get_db
|
||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["activity"])
|
||||
|
||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||
|
||||
|
||||
def _humanize_age(seconds: float) -> str:
|
||||
if seconds < 60:
|
||||
return "just now"
|
||||
if seconds < 3600:
|
||||
return f"{int(seconds / 60)}m ago"
|
||||
if seconds < 86400:
|
||||
hrs = seconds / 3600
|
||||
return f"{int(hrs)}h {int((hrs % 1) * 60)}m ago"
|
||||
return f"{int(seconds / 86400)}d ago"
|
||||
|
||||
PHOTOS_BASE_DIR = Path("data/photos")
|
||||
|
||||
|
||||
@@ -144,3 +162,86 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
|
||||
"hours": hours,
|
||||
"time_threshold": time_threshold.isoformat()
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recent-event-callins")
|
||||
async def get_recent_event_callins(limit: int = 10, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Recent unit call-ins derived from SFM event forwards.
|
||||
|
||||
Architecture context: the live ACH replacement is on hold, so call-homes
|
||||
arrive as Blastware ACH event files forwarded by series3-watcher and
|
||||
landed in the SFM events store. One event ≈ one call-in. This is the
|
||||
forward-looking source of "recent call-ins" that will eventually replace
|
||||
the heartbeat-based /recent-callins endpoint entirely.
|
||||
|
||||
Each row represents one event; multiple consecutive events from the same
|
||||
serial are intentionally NOT collapsed — each one is a distinct call-home.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"{SFM_BASE_URL}/db/events",
|
||||
params={"limit": limit},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except httpx.HTTPError as e:
|
||||
log.warning("SFM /db/events failed for recent-event-callins: %s", e)
|
||||
return {"call_ins": [], "total": 0, "error": str(e)}
|
||||
|
||||
events = payload.get("events", []) or []
|
||||
|
||||
# Bulk-resolve serials → roster (single query, no N+1)
|
||||
serials = list({ev.get("serial") for ev in events if ev.get("serial")})
|
||||
roster_map: Dict[str, RosterUnit] = {}
|
||||
if serials:
|
||||
roster_map = {
|
||||
r.id: r
|
||||
for r in db.query(RosterUnit).filter(RosterUnit.id.in_(serials)).all()
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
call_ins: List[Dict[str, Any]] = []
|
||||
|
||||
for ev in events:
|
||||
serial = ev.get("serial")
|
||||
if not serial:
|
||||
continue
|
||||
|
||||
roster = roster_map.get(serial)
|
||||
|
||||
# created_at = when SFM received the forward. Falls back to the event
|
||||
# timestamp if the SFM payload didn't carry created_at (older rows).
|
||||
created_at_str = ev.get("created_at") or ev.get("timestamp")
|
||||
time_ago = "—"
|
||||
if created_at_str:
|
||||
try:
|
||||
ts = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
time_ago = _humanize_age((now - ts).total_seconds())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
call_ins.append({
|
||||
"unit_id": serial,
|
||||
"serial": serial,
|
||||
"event_id": ev.get("id"),
|
||||
"event_timestamp": ev.get("timestamp"),
|
||||
"created_at": ev.get("created_at"),
|
||||
"time_ago": time_ago,
|
||||
"peak_vector_sum": ev.get("peak_vector_sum"),
|
||||
"false_trigger": bool(ev.get("false_trigger")),
|
||||
"sensor_location": ev.get("sensor_location") or "",
|
||||
"project": ev.get("project") or "",
|
||||
"device_type": roster.device_type if roster else "seismograph",
|
||||
"in_roster": roster is not None,
|
||||
"note": (roster.note if roster else "") or "",
|
||||
})
|
||||
|
||||
return {
|
||||
"call_ins": call_ins,
|
||||
"total": len(call_ins),
|
||||
"source": "sfm-events",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, date
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import DeploymentRecord, RosterUnit
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["deployments"])
|
||||
|
||||
|
||||
def _serialize(record: DeploymentRecord) -> dict:
|
||||
return {
|
||||
"id": record.id,
|
||||
"unit_id": record.unit_id,
|
||||
"deployed_date": record.deployed_date.isoformat() if record.deployed_date else None,
|
||||
"estimated_removal_date": record.estimated_removal_date.isoformat() if record.estimated_removal_date else None,
|
||||
"actual_removal_date": record.actual_removal_date.isoformat() if record.actual_removal_date else None,
|
||||
"project_ref": record.project_ref,
|
||||
"project_id": record.project_id,
|
||||
"location_name": record.location_name,
|
||||
"notes": record.notes,
|
||||
"created_at": record.created_at.isoformat() if record.created_at else None,
|
||||
"updated_at": record.updated_at.isoformat() if record.updated_at else None,
|
||||
"is_active": record.actual_removal_date is None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/deployments/{unit_id}")
|
||||
def get_deployments(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Get all deployment records for a unit, newest first."""
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||
|
||||
records = (
|
||||
db.query(DeploymentRecord)
|
||||
.filter_by(unit_id=unit_id)
|
||||
.order_by(DeploymentRecord.deployed_date.desc(), DeploymentRecord.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return {"deployments": [_serialize(r) for r in records]}
|
||||
|
||||
|
||||
@router.get("/deployments/{unit_id}/active")
|
||||
def get_active_deployment(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Get the current active deployment (actual_removal_date is NULL), or null."""
|
||||
record = (
|
||||
db.query(DeploymentRecord)
|
||||
.filter(
|
||||
DeploymentRecord.unit_id == unit_id,
|
||||
DeploymentRecord.actual_removal_date == None
|
||||
)
|
||||
.order_by(DeploymentRecord.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
return {"deployment": _serialize(record) if record else None}
|
||||
|
||||
|
||||
@router.post("/deployments/{unit_id}")
|
||||
def create_deployment(unit_id: str, payload: dict, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Create a new deployment record for a unit.
|
||||
|
||||
Body fields (all optional):
|
||||
deployed_date (YYYY-MM-DD)
|
||||
estimated_removal_date (YYYY-MM-DD)
|
||||
project_ref (freeform string)
|
||||
project_id (UUID if linked to Project)
|
||||
location_name
|
||||
notes
|
||||
"""
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||
|
||||
def parse_date(val) -> Optional[date]:
|
||||
if not val:
|
||||
return None
|
||||
if isinstance(val, date):
|
||||
return val
|
||||
return date.fromisoformat(str(val))
|
||||
|
||||
record = DeploymentRecord(
|
||||
id=str(uuid.uuid4()),
|
||||
unit_id=unit_id,
|
||||
deployed_date=parse_date(payload.get("deployed_date")),
|
||||
estimated_removal_date=parse_date(payload.get("estimated_removal_date")),
|
||||
actual_removal_date=None,
|
||||
project_ref=payload.get("project_ref"),
|
||||
project_id=payload.get("project_id"),
|
||||
location_name=payload.get("location_name"),
|
||||
notes=payload.get("notes"),
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return _serialize(record)
|
||||
|
||||
|
||||
@router.put("/deployments/{unit_id}/{deployment_id}")
|
||||
def update_deployment(unit_id: str, deployment_id: str, payload: dict, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Update a deployment record. Used for:
|
||||
- Setting/changing estimated_removal_date
|
||||
- Closing a deployment (set actual_removal_date to mark unit returned)
|
||||
- Editing project_ref, location_name, notes
|
||||
"""
|
||||
record = db.query(DeploymentRecord).filter_by(id=deployment_id, unit_id=unit_id).first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Deployment record not found")
|
||||
|
||||
def parse_date(val) -> Optional[date]:
|
||||
if val is None:
|
||||
return None
|
||||
if val == "":
|
||||
return None
|
||||
if isinstance(val, date):
|
||||
return val
|
||||
return date.fromisoformat(str(val))
|
||||
|
||||
if "deployed_date" in payload:
|
||||
record.deployed_date = parse_date(payload["deployed_date"])
|
||||
if "estimated_removal_date" in payload:
|
||||
record.estimated_removal_date = parse_date(payload["estimated_removal_date"])
|
||||
if "actual_removal_date" in payload:
|
||||
record.actual_removal_date = parse_date(payload["actual_removal_date"])
|
||||
if "project_ref" in payload:
|
||||
record.project_ref = payload["project_ref"]
|
||||
if "project_id" in payload:
|
||||
record.project_id = payload["project_id"]
|
||||
if "location_name" in payload:
|
||||
record.location_name = payload["location_name"]
|
||||
if "notes" in payload:
|
||||
record.notes = payload["notes"]
|
||||
|
||||
record.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return _serialize(record)
|
||||
|
||||
|
||||
@router.delete("/deployments/{unit_id}/{deployment_id}")
|
||||
def delete_deployment(unit_id: str, deployment_id: str, db: Session = Depends(get_db)):
|
||||
"""Delete a deployment record."""
|
||||
record = db.query(DeploymentRecord).filter_by(id=deployment_id, unit_id=unit_id).first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Deployment record not found")
|
||||
db.delete(record)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -19,7 +19,7 @@ import logging
|
||||
from backend.database import get_db
|
||||
from backend.models import (
|
||||
RosterUnit, JobReservation, JobReservationUnit,
|
||||
UserPreferences, Project
|
||||
UserPreferences, Project, MonitoringLocation, UnitAssignment
|
||||
)
|
||||
from backend.templates_config import templates
|
||||
from backend.services.fleet_calendar_service import (
|
||||
@@ -61,9 +61,53 @@ async def fleet_calendar_page(
|
||||
|
||||
# Get projects for the reservation form dropdown
|
||||
projects = db.query(Project).filter(
|
||||
Project.status == "active"
|
||||
Project.status.in_(["active", "upcoming", "on_hold"])
|
||||
).order_by(Project.name).all()
|
||||
|
||||
# Build a serializable list of items with dates for calendar bars
|
||||
# Includes both tracked Projects (with dates) and Job Reservations (matching device_type)
|
||||
project_colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316']
|
||||
# Map calendar device_type to project_type_ids
|
||||
device_type_to_project_types = {
|
||||
"seismograph": ["vibration_monitoring", "combined"],
|
||||
"slm": ["sound_monitoring", "combined"],
|
||||
}
|
||||
relevant_project_types = device_type_to_project_types.get(device_type, [])
|
||||
|
||||
calendar_projects = []
|
||||
for i, p in enumerate(projects):
|
||||
if p.start_date and p.project_type_id in relevant_project_types:
|
||||
calendar_projects.append({
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"start_date": p.start_date.isoformat(),
|
||||
"end_date": p.end_date.isoformat() if p.end_date else None,
|
||||
"color": project_colors[i % len(project_colors)],
|
||||
"confirmed": True,
|
||||
})
|
||||
|
||||
# Add job reservations for this device_type as bars
|
||||
from sqlalchemy import or_ as _or
|
||||
cal_window_end = date(year + ((month + 10) // 12), ((month + 10) % 12) + 1, 1)
|
||||
reservations_for_cal = db.query(JobReservation).filter(
|
||||
JobReservation.device_type == device_type,
|
||||
JobReservation.start_date <= cal_window_end,
|
||||
_or(
|
||||
JobReservation.end_date >= date(year, month, 1),
|
||||
JobReservation.end_date == None,
|
||||
)
|
||||
).all()
|
||||
for res in reservations_for_cal:
|
||||
end = res.end_date or res.estimated_end_date
|
||||
calendar_projects.append({
|
||||
"id": res.id,
|
||||
"name": res.name,
|
||||
"start_date": res.start_date.isoformat(),
|
||||
"end_date": end.isoformat() if end else None,
|
||||
"color": res.color,
|
||||
"confirmed": bool(res.project_id),
|
||||
})
|
||||
|
||||
# Calculate prev/next month navigation
|
||||
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||
@@ -81,6 +125,7 @@ async def fleet_calendar_page(
|
||||
"device_type": device_type,
|
||||
"calendar_data": calendar_data,
|
||||
"projects": projects,
|
||||
"calendar_projects": calendar_projects,
|
||||
"today": today.isoformat()
|
||||
}
|
||||
)
|
||||
@@ -167,6 +212,7 @@ async def create_reservation(
|
||||
if estimated_end_date and estimated_end_date < start_date:
|
||||
raise HTTPException(status_code=400, detail="Estimated end date must be after start date")
|
||||
|
||||
import json as _json
|
||||
reservation = JobReservation(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data["name"],
|
||||
@@ -178,6 +224,8 @@ async def create_reservation(
|
||||
assignment_type=data["assignment_type"],
|
||||
device_type=data.get("device_type", "seismograph"),
|
||||
quantity_needed=data.get("quantity_needed"),
|
||||
estimated_units=data.get("estimated_units"),
|
||||
location_slots=_json.dumps(data["location_slots"]) if data.get("location_slots") is not None else None,
|
||||
notes=data.get("notes"),
|
||||
color=data.get("color", "#3B82F6")
|
||||
)
|
||||
@@ -221,8 +269,16 @@ async def get_reservation(
|
||||
reservation_id=reservation_id
|
||||
).all()
|
||||
|
||||
unit_ids = [a.unit_id for a in assignments]
|
||||
# Sort assignments by slot_index so order is preserved
|
||||
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||
unit_ids = [a.unit_id for a in assignments_sorted]
|
||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
||||
units_by_id = {u.id: u for u in units}
|
||||
# Build per-unit lookups from assignments
|
||||
assignment_map = {a.unit_id: a for a in assignments_sorted}
|
||||
|
||||
import json as _json
|
||||
stored_slots = _json.loads(reservation.location_slots) if reservation.location_slots else None
|
||||
|
||||
return {
|
||||
"id": reservation.id,
|
||||
@@ -235,15 +291,21 @@ async def get_reservation(
|
||||
"assignment_type": reservation.assignment_type,
|
||||
"device_type": reservation.device_type,
|
||||
"quantity_needed": reservation.quantity_needed,
|
||||
"estimated_units": reservation.estimated_units,
|
||||
"location_slots": stored_slots,
|
||||
"notes": reservation.notes,
|
||||
"color": reservation.color,
|
||||
"assigned_units": [
|
||||
{
|
||||
"id": u.id,
|
||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||
"deployed": u.deployed
|
||||
"id": uid,
|
||||
"last_calibrated": units_by_id[uid].last_calibrated.isoformat() if uid in units_by_id and units_by_id[uid].last_calibrated else None,
|
||||
"deployed": units_by_id[uid].deployed if uid in units_by_id else False,
|
||||
"power_type": assignment_map[uid].power_type,
|
||||
"notes": assignment_map[uid].notes,
|
||||
"location_name": assignment_map[uid].location_name,
|
||||
"slot_index": assignment_map[uid].slot_index,
|
||||
}
|
||||
for u in units
|
||||
for uid in unit_ids
|
||||
]
|
||||
}
|
||||
|
||||
@@ -278,6 +340,11 @@ async def update_reservation(
|
||||
reservation.assignment_type = data["assignment_type"]
|
||||
if "quantity_needed" in data:
|
||||
reservation.quantity_needed = data["quantity_needed"]
|
||||
if "estimated_units" in data:
|
||||
reservation.estimated_units = data["estimated_units"]
|
||||
if "location_slots" in data:
|
||||
import json as _json
|
||||
reservation.location_slots = _json.dumps(data["location_slots"]) if data["location_slots"] is not None else None
|
||||
if "notes" in data:
|
||||
reservation.notes = data["notes"]
|
||||
if "color" in data:
|
||||
@@ -337,52 +404,57 @@ async def assign_units_to_reservation(
|
||||
|
||||
data = await request.json()
|
||||
unit_ids = data.get("unit_ids", [])
|
||||
# Optional per-unit dicts keyed by unit_id
|
||||
power_types = data.get("power_types", {})
|
||||
location_notes = data.get("location_notes", {})
|
||||
location_names = data.get("location_names", {})
|
||||
# slot_indices: {"BE17354": 0, "BE9441": 1, ...}
|
||||
slot_indices = data.get("slot_indices", {})
|
||||
|
||||
if not unit_ids:
|
||||
raise HTTPException(status_code=400, detail="No units specified")
|
||||
# Verify units exist (allow empty list to clear all assignments)
|
||||
if unit_ids:
|
||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
|
||||
found_ids = {u.id for u in units}
|
||||
missing = set(unit_ids) - found_ids
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
|
||||
|
||||
# Verify units exist
|
||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
|
||||
found_ids = {u.id for u in units}
|
||||
missing = set(unit_ids) - found_ids
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
|
||||
# Full replace: delete all existing assignments for this reservation first
|
||||
db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete()
|
||||
db.flush()
|
||||
|
||||
# Check for conflicts (already assigned to overlapping reservations)
|
||||
# Check for conflicts with other reservations and insert new assignments
|
||||
conflicts = []
|
||||
for unit_id in unit_ids:
|
||||
# Check if unit is already assigned to this reservation
|
||||
existing = db.query(JobReservationUnit).filter_by(
|
||||
reservation_id=reservation_id,
|
||||
unit_id=unit_id
|
||||
).first()
|
||||
if existing:
|
||||
continue # Already assigned, skip
|
||||
|
||||
# Check overlapping reservations
|
||||
overlapping = db.query(JobReservation).join(
|
||||
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
|
||||
).filter(
|
||||
JobReservationUnit.unit_id == unit_id,
|
||||
JobReservation.id != reservation_id,
|
||||
JobReservation.start_date <= reservation.end_date,
|
||||
JobReservation.end_date >= reservation.start_date
|
||||
).first()
|
||||
if reservation.end_date:
|
||||
overlapping = db.query(JobReservation).join(
|
||||
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
|
||||
).filter(
|
||||
JobReservationUnit.unit_id == unit_id,
|
||||
JobReservation.id != reservation_id,
|
||||
JobReservation.start_date <= reservation.end_date,
|
||||
JobReservation.end_date >= reservation.start_date
|
||||
).first()
|
||||
|
||||
if overlapping:
|
||||
conflicts.append({
|
||||
"unit_id": unit_id,
|
||||
"conflict_reservation": overlapping.name,
|
||||
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
|
||||
})
|
||||
continue
|
||||
if overlapping:
|
||||
conflicts.append({
|
||||
"unit_id": unit_id,
|
||||
"conflict_reservation": overlapping.name,
|
||||
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
|
||||
})
|
||||
continue
|
||||
|
||||
# Add assignment
|
||||
assignment = JobReservationUnit(
|
||||
id=str(uuid.uuid4()),
|
||||
reservation_id=reservation_id,
|
||||
unit_id=unit_id,
|
||||
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific"
|
||||
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific",
|
||||
power_type=power_types.get(unit_id),
|
||||
notes=location_notes.get(unit_id),
|
||||
location_name=location_names.get(unit_id),
|
||||
slot_index=slot_indices.get(unit_id),
|
||||
)
|
||||
db.add(assignment)
|
||||
|
||||
@@ -511,7 +583,7 @@ async def get_reservations_list(
|
||||
else:
|
||||
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
|
||||
|
||||
# Include TBD reservations that started before window end
|
||||
# Filter by device_type and date window
|
||||
reservations = db.query(JobReservation).filter(
|
||||
JobReservation.device_type == device_type,
|
||||
JobReservation.start_date <= end_date,
|
||||
@@ -524,16 +596,38 @@ async def get_reservations_list(
|
||||
# Get assignment counts
|
||||
reservation_data = []
|
||||
for res in reservations:
|
||||
assigned_count = db.query(JobReservationUnit).filter_by(
|
||||
assignments = db.query(JobReservationUnit).filter_by(
|
||||
reservation_id=res.id
|
||||
).count()
|
||||
).all()
|
||||
assigned_count = len(assignments)
|
||||
|
||||
# Enrich assignments with unit details, sorted by slot_index
|
||||
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||
unit_ids = [a.unit_id for a in assignments_sorted]
|
||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
||||
units_by_id = {u.id: u for u in units}
|
||||
assigned_units = [
|
||||
{
|
||||
"id": a.unit_id,
|
||||
"power_type": a.power_type,
|
||||
"notes": a.notes,
|
||||
"location_name": a.location_name,
|
||||
"slot_index": a.slot_index,
|
||||
"deployed": units_by_id[a.unit_id].deployed if a.unit_id in units_by_id else False,
|
||||
"last_calibrated": units_by_id[a.unit_id].last_calibrated if a.unit_id in units_by_id else None,
|
||||
}
|
||||
for a in assignments_sorted
|
||||
]
|
||||
|
||||
# Check for calibration conflicts
|
||||
conflicts = check_calibration_conflicts(db, res.id)
|
||||
|
||||
location_count = res.quantity_needed or assigned_count
|
||||
reservation_data.append({
|
||||
"reservation": res,
|
||||
"assigned_count": assigned_count,
|
||||
"location_count": location_count,
|
||||
"assigned_units": assigned_units,
|
||||
"has_conflicts": len(conflicts) > 0,
|
||||
"conflict_count": len(conflicts)
|
||||
})
|
||||
@@ -549,6 +643,131 @@ async def get_reservations_list(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/fleet-calendar/planner-availability", response_class=JSONResponse)
|
||||
async def get_planner_availability(
|
||||
device_type: str = "seismograph",
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
exclude_reservation_id: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get available units for the reservation planner split-panel UI.
|
||||
Dates are optional — if omitted, returns all non-retired units regardless of reservations.
|
||||
"""
|
||||
if start_date and end_date:
|
||||
try:
|
||||
start = date.fromisoformat(start_date)
|
||||
end = date.fromisoformat(end_date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
units = get_available_units_for_period(db, start, end, device_type, exclude_reservation_id)
|
||||
else:
|
||||
# No dates: return all non-retired units of this type, with current reservation info
|
||||
from backend.models import RosterUnit as RU
|
||||
from datetime import timedelta
|
||||
today = date.today()
|
||||
all_units = db.query(RU).filter(
|
||||
RU.device_type == device_type,
|
||||
RU.retired == False
|
||||
).all()
|
||||
|
||||
# Build a map: unit_id -> list of active/upcoming reservations
|
||||
active_assignments = db.query(JobReservationUnit).join(
|
||||
JobReservation, JobReservationUnit.reservation_id == JobReservation.id
|
||||
).filter(
|
||||
JobReservation.device_type == device_type,
|
||||
JobReservation.end_date >= today
|
||||
).all()
|
||||
unit_reservations = {}
|
||||
for assignment in active_assignments:
|
||||
res = db.query(JobReservation).filter(JobReservation.id == assignment.reservation_id).first()
|
||||
if not res:
|
||||
continue
|
||||
unit_reservations.setdefault(assignment.unit_id, []).append({
|
||||
"reservation_id": res.id,
|
||||
"reservation_name": res.name,
|
||||
"start_date": res.start_date.isoformat() if res.start_date else None,
|
||||
"end_date": res.end_date.isoformat() if res.end_date else None,
|
||||
"color": res.color or "#3B82F6"
|
||||
})
|
||||
|
||||
units = []
|
||||
for u in all_units:
|
||||
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
|
||||
units.append({
|
||||
"id": u.id,
|
||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||
"expiry_date": expiry.isoformat() if expiry else None,
|
||||
"calibration_status": "needs_calibration" if not u.last_calibrated else "valid",
|
||||
"deployed": u.deployed,
|
||||
"out_for_calibration": u.out_for_calibration or False,
|
||||
"allocated": getattr(u, 'allocated', False) or False,
|
||||
"allocated_to_project_id": getattr(u, 'allocated_to_project_id', None) or "",
|
||||
"note": u.note or "",
|
||||
"reservations": unit_reservations.get(u.id, [])
|
||||
})
|
||||
|
||||
# Sort: benched first (easier to assign), then deployed, then by ID
|
||||
units.sort(key=lambda u: (1 if u["deployed"] else 0, u["id"]))
|
||||
|
||||
return {
|
||||
"units": units,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"count": len(units)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/fleet-calendar/unit-quick-info/{unit_id}", response_class=JSONResponse)
|
||||
async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Return at-a-glance info for the planner quick-view modal."""
|
||||
from backend.models import Emitter
|
||||
u = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
|
||||
today = date.today()
|
||||
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
|
||||
|
||||
# Active/upcoming reservations
|
||||
assignments = db.query(JobReservationUnit).filter(JobReservationUnit.unit_id == unit_id).all()
|
||||
reservations = []
|
||||
for a in assignments:
|
||||
res = db.query(JobReservation).filter(
|
||||
JobReservation.id == a.reservation_id,
|
||||
JobReservation.end_date >= today
|
||||
).first()
|
||||
if res:
|
||||
reservations.append({
|
||||
"name": res.name,
|
||||
"start_date": res.start_date.isoformat() if res.start_date else None,
|
||||
"end_date": res.end_date.isoformat() if res.end_date else None,
|
||||
"end_date_tbd": res.end_date_tbd,
|
||||
"color": res.color or "#3B82F6",
|
||||
"location_name": a.location_name,
|
||||
})
|
||||
|
||||
# Last seen from emitter
|
||||
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
|
||||
|
||||
return {
|
||||
"id": u.id,
|
||||
"unit_type": u.unit_type,
|
||||
"deployed": u.deployed,
|
||||
"out_for_calibration": u.out_for_calibration or False,
|
||||
"note": u.note or "",
|
||||
"project_id": u.project_id or "",
|
||||
"address": u.address or u.location or "",
|
||||
"coordinates": u.coordinates or "",
|
||||
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
||||
"cal_expired": not u.last_calibrated or (expiry and expiry < today),
|
||||
"last_seen": emitter.last_seen.isoformat() if emitter and emitter.last_seen else None,
|
||||
"reservations": reservations,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
|
||||
async def get_available_units_partial(
|
||||
request: Request,
|
||||
@@ -608,3 +827,102 @@ async def get_month_partial(
|
||||
"today": date.today().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Promote Reservation to Project
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/api/fleet-calendar/reservations/{reservation_id}/promote-to-project", response_class=JSONResponse)
|
||||
async def promote_reservation_to_project(
|
||||
reservation_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Promote a job reservation to a full project in the projects DB.
|
||||
Creates: Project + MonitoringLocations + UnitAssignments.
|
||||
"""
|
||||
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
|
||||
if not reservation:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
|
||||
data = await request.json()
|
||||
project_number = data.get("project_number") or None
|
||||
client_name = data.get("client_name") or None
|
||||
|
||||
# Map device_type to project_type_id
|
||||
if reservation.device_type == "slm":
|
||||
project_type_id = "sound_monitoring"
|
||||
location_type = "sound"
|
||||
else:
|
||||
project_type_id = "vibration_monitoring"
|
||||
location_type = "vibration"
|
||||
|
||||
# Check for duplicate project name
|
||||
existing = db.query(Project).filter_by(name=reservation.name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail=f"A project named '{reservation.name}' already exists.")
|
||||
|
||||
# Create the project
|
||||
project_id = str(uuid.uuid4())
|
||||
project = Project(
|
||||
id=project_id,
|
||||
name=reservation.name,
|
||||
project_number=project_number,
|
||||
client_name=client_name,
|
||||
project_type_id=project_type_id,
|
||||
status="upcoming",
|
||||
start_date=reservation.start_date,
|
||||
end_date=reservation.end_date,
|
||||
description=reservation.notes,
|
||||
)
|
||||
db.add(project)
|
||||
db.flush()
|
||||
|
||||
# Load assignments sorted by slot_index
|
||||
assignments = db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).all()
|
||||
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||
|
||||
locations_created = 0
|
||||
units_assigned = 0
|
||||
|
||||
for i, assignment in enumerate(assignments_sorted):
|
||||
loc_num = str(i + 1).zfill(3)
|
||||
loc_name = assignment.location_name or f"Location {i + 1}"
|
||||
|
||||
location = MonitoringLocation(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project_id,
|
||||
location_type=location_type,
|
||||
name=loc_name,
|
||||
description=assignment.notes,
|
||||
)
|
||||
db.add(location)
|
||||
db.flush()
|
||||
locations_created += 1
|
||||
|
||||
if assignment.unit_id:
|
||||
unit_assignment = UnitAssignment(
|
||||
id=str(uuid.uuid4()),
|
||||
unit_id=assignment.unit_id,
|
||||
location_id=location.id,
|
||||
project_id=project_id,
|
||||
device_type=reservation.device_type or "seismograph",
|
||||
status="active",
|
||||
notes=f"Power: {assignment.power_type}" if assignment.power_type else None,
|
||||
)
|
||||
db.add(unit_assignment)
|
||||
units_assigned += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Promoted reservation '{reservation.name}' to project {project_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"project_id": project_id,
|
||||
"project_name": reservation.name,
|
||||
"locations_created": locations_created,
|
||||
"units_assigned": units_assigned,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
Metadata-backfill admin router.
|
||||
|
||||
Endpoints under /api/admin/metadata_backfill:
|
||||
|
||||
GET /scan — run the scan; return clusters + suggestions (JSON).
|
||||
Cached 5 minutes so the wizard doesn't re-scan on
|
||||
every page render.
|
||||
POST /apply — apply a list of cluster_ids; body specifies which to
|
||||
accept and optional per-cluster overrides.
|
||||
POST /skip — mark cluster_ids as skipped (won't reappear).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import Project, MonitoringLocation
|
||||
from backend.services import metadata_backfill as svc
|
||||
|
||||
router = APIRouter(prefix="/api/admin/metadata_backfill", tags=["metadata-backfill"])
|
||||
|
||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||
|
||||
# In-process scan cache. Trades memory for not re-hammering SFM on every
|
||||
# wizard render. TTL: 5 minutes. Singleton per-process; fine for a
|
||||
# single-worker uvicorn dev setup. For prod multi-worker we'd want to put
|
||||
# this in the DB or Redis; deferred.
|
||||
_SCAN_CACHE: dict = {"at": 0.0, "result": None}
|
||||
_SCAN_CACHE_TTL_SECONDS = 300.0
|
||||
|
||||
|
||||
def _serialise_suggestion(s: svc.Suggestion) -> dict:
|
||||
c = s.cluster
|
||||
return {
|
||||
"cluster_id": c.cluster_id,
|
||||
"serial": c.serial,
|
||||
"first_event_ts": c.first_event_ts.isoformat(),
|
||||
"last_event_ts": c.last_event_ts.isoformat(),
|
||||
"event_count": c.event_count,
|
||||
"sample_event_id": c.sample_event_id,
|
||||
"project_raw": c.project_raw,
|
||||
"project_root": c.project_root,
|
||||
"location_raw": c.location_raw,
|
||||
"client_raw": c.client_raw,
|
||||
"operator_raw": c.operator_raw,
|
||||
"is_blank_meta": c.is_blank_meta,
|
||||
"metadata_consistency": c.metadata_consistency,
|
||||
|
||||
"project_match": s.project_match,
|
||||
"project_existing_id": s.project_existing_id,
|
||||
"project_existing_name": s.project_existing_name,
|
||||
"project_match_score": s.project_match_score,
|
||||
"project_suggested_name": s.project_suggested_name,
|
||||
|
||||
"location_match": s.location_match,
|
||||
"location_existing_id": s.location_existing_id,
|
||||
"location_existing_name": s.location_existing_name,
|
||||
"location_match_score": s.location_match_score,
|
||||
"location_suggested_name": s.location_suggested_name,
|
||||
|
||||
"proposed_assigned_at": s.proposed_assigned_at.isoformat(),
|
||||
"proposed_assigned_until": s.proposed_assigned_until.isoformat() if s.proposed_assigned_until else None,
|
||||
|
||||
"confidence": s.confidence,
|
||||
"blocking_conflict": s.blocking_conflict,
|
||||
"conflicts": [
|
||||
{
|
||||
"existing_assignment_id": cf.existing_assignment_id,
|
||||
"other_location_id": cf.other_location_id,
|
||||
"other_location_name": cf.other_location_name,
|
||||
"other_project_id": cf.other_project_id,
|
||||
"other_project_name": cf.other_project_name,
|
||||
}
|
||||
for cf in s.conflicts
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/scan")
|
||||
async def scan(
|
||||
force: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Run a scan and return clusters + suggestions.
|
||||
|
||||
Set force=true to bypass the 5-minute cache.
|
||||
"""
|
||||
now = time.time()
|
||||
if not force and _SCAN_CACHE["result"] is not None \
|
||||
and (now - _SCAN_CACHE["at"]) < _SCAN_CACHE_TTL_SECONDS:
|
||||
return _SCAN_CACHE["result"]
|
||||
|
||||
result = await svc.scan_clusters_and_build_suggestions(db, SFM_BASE_URL)
|
||||
|
||||
# Group suggestions for the wizard UI.
|
||||
by_confidence = {"high": [], "medium": [], "low": []}
|
||||
blocking_conflict_count = 0
|
||||
for s in result.suggestions:
|
||||
by_confidence[s.confidence].append(_serialise_suggestion(s))
|
||||
if s.blocking_conflict:
|
||||
blocking_conflict_count += 1
|
||||
|
||||
payload = {
|
||||
"scanned_event_count": result.scanned_event_count,
|
||||
"cluster_count": result.cluster_count,
|
||||
"already_attributed": result.already_attributed,
|
||||
"skipped_orphans": result.skipped_orphans,
|
||||
"pending_count": len(result.suggestions),
|
||||
"blocking_conflict_count": blocking_conflict_count,
|
||||
"by_confidence": {
|
||||
"high": by_confidence["high"],
|
||||
"medium": by_confidence["medium"],
|
||||
"low": by_confidence["low"],
|
||||
},
|
||||
"scanned_at": now,
|
||||
}
|
||||
_SCAN_CACHE["result"] = payload
|
||||
_SCAN_CACHE["at"] = now
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("/apply")
|
||||
async def apply(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Apply a list of clusters.
|
||||
|
||||
Body:
|
||||
{
|
||||
"cluster_ids": ["abc...", "def..."],
|
||||
"overrides": { "abc...": { "project_name": "...", "location_name": "..." } }
|
||||
}
|
||||
|
||||
To accept ALL non-conflict suggestions in one shot, the UI sends every
|
||||
pending cluster_id with no overrides.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
|
||||
cluster_ids = body.get("cluster_ids") or []
|
||||
overrides = body.get("overrides") or {}
|
||||
if not isinstance(cluster_ids, list) or not cluster_ids:
|
||||
raise HTTPException(status_code=400, detail="cluster_ids must be a non-empty list")
|
||||
|
||||
# Re-scan to get current suggestions. We don't trust the cached scan
|
||||
# blindly — the operator might have manually created projects in
|
||||
# between scan and apply.
|
||||
scan_result = await svc.scan_clusters_and_build_suggestions(db, SFM_BASE_URL)
|
||||
suggestions_by_id = {s.cluster.cluster_id: s for s in scan_result.suggestions}
|
||||
|
||||
selected: list[svc.Suggestion] = []
|
||||
not_found: list[str] = []
|
||||
for cid in cluster_ids:
|
||||
s = suggestions_by_id.get(cid)
|
||||
if s is None:
|
||||
not_found.append(cid)
|
||||
continue
|
||||
# Apply overrides. Per-cluster overrides take precedence over the
|
||||
# parser's suggested match. Four override fields supported:
|
||||
# project_id — attach to an existing Project (operator picked
|
||||
# from the typeahead)
|
||||
# project_name — create new project with this name (operator
|
||||
# typed a custom name not matching anything)
|
||||
# location_id — attach to an existing MonitoringLocation
|
||||
# location_name — create new location with this name
|
||||
# project_id + location_id pairings: location_id is only honored
|
||||
# if its project_id matches the chosen project (otherwise treated
|
||||
# as a create-new).
|
||||
ov = overrides.get(cid) or {}
|
||||
|
||||
if ov.get("project_id"):
|
||||
target_id = ov["project_id"]
|
||||
existing = db.query(svc.Project).filter_by(id=target_id).first()
|
||||
if existing is not None:
|
||||
s.project_existing_id = existing.id
|
||||
s.project_existing_name = existing.name
|
||||
s.project_suggested_name = existing.name
|
||||
s.project_match = "exact"
|
||||
else:
|
||||
# Stale ID — treat as create_new with the cluster's typed name.
|
||||
s.project_existing_id = None
|
||||
s.project_match = "create_new"
|
||||
elif "project_name" in ov:
|
||||
new_name = (ov["project_name"] or "").strip()
|
||||
if new_name:
|
||||
s.project_suggested_name = new_name
|
||||
s.project_existing_id = None
|
||||
s.project_existing_name = None
|
||||
s.project_match = "create_new"
|
||||
|
||||
if ov.get("location_id"):
|
||||
target_id = ov["location_id"]
|
||||
existing = db.query(svc.MonitoringLocation).filter_by(id=target_id).first()
|
||||
# Only attach if the location belongs to the (now chosen) project.
|
||||
chosen_project_id = s.project_existing_id
|
||||
if existing is not None and (
|
||||
chosen_project_id is None or existing.project_id == chosen_project_id
|
||||
):
|
||||
s.location_existing_id = existing.id
|
||||
s.location_existing_name = existing.name
|
||||
s.location_suggested_name = existing.name
|
||||
s.location_match = "exact"
|
||||
else:
|
||||
s.location_existing_id = None
|
||||
s.location_match = "create_new"
|
||||
elif "location_name" in ov:
|
||||
new_name = (ov["location_name"] or "").strip()
|
||||
if new_name:
|
||||
s.location_suggested_name = new_name
|
||||
s.location_existing_id = None
|
||||
s.location_existing_name = None
|
||||
s.location_match = "create_new"
|
||||
|
||||
selected.append(s)
|
||||
|
||||
apply_result = svc.apply_suggestions(db, selected, decided_by="operator")
|
||||
|
||||
# Invalidate the scan cache so the next /scan picks up the new state.
|
||||
_SCAN_CACHE["at"] = 0.0
|
||||
_SCAN_CACHE["result"] = None
|
||||
|
||||
return {
|
||||
"applied": apply_result.applied,
|
||||
"failed": [{"cluster_id": cid, "reason": r} for cid, r in apply_result.failed],
|
||||
"not_found": not_found,
|
||||
"project_ids_created": apply_result.project_ids_created,
|
||||
"location_ids_created": apply_result.location_ids_created,
|
||||
"assignment_ids_created": apply_result.assignment_ids_created,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/skip")
|
||||
async def skip(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Mark cluster_ids as skipped — they won't reappear in future scans."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
|
||||
cluster_ids = body.get("cluster_ids") or []
|
||||
if not isinstance(cluster_ids, list):
|
||||
raise HTTPException(status_code=400, detail="cluster_ids must be a list")
|
||||
|
||||
n = svc.skip_clusters(db, cluster_ids, decided_by="operator")
|
||||
|
||||
_SCAN_CACHE["at"] = 0.0
|
||||
_SCAN_CACHE["result"] = None
|
||||
|
||||
return {"skipped": n}
|
||||
|
||||
|
||||
@router.get("/projects_search")
|
||||
def projects_search(
|
||||
q: str = "",
|
||||
limit: int = 10,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Typeahead search of existing projects for the wizard's per-cluster
|
||||
override inputs. Combines case-insensitive substring match with
|
||||
rapidfuzz scoring so partial typing and slight typos both surface
|
||||
candidates. Always returns a 'Create new' option at the end so the
|
||||
operator can confirm they want to create rather than match.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"matches": [
|
||||
{"id": "...", "name": "...", "score": 0.91, "location_count": 3},
|
||||
...
|
||||
],
|
||||
"create_new": {"label": "Create new: \"<q>\""}
|
||||
}
|
||||
"""
|
||||
q_clean = (q or "").strip()
|
||||
q_norm = svc._normalise(q_clean)
|
||||
|
||||
projects = (
|
||||
db.query(Project)
|
||||
.filter(Project.status != "deleted")
|
||||
.all()
|
||||
)
|
||||
|
||||
scored: list[tuple[Project, float]] = []
|
||||
for p in projects:
|
||||
p_norm = svc._normalise(p.name)
|
||||
if not q_norm:
|
||||
# Empty query → return top projects by latest activity
|
||||
# (cheap heuristic: keep them all and sort by name).
|
||||
scored.append((p, 0.0))
|
||||
continue
|
||||
# Cheap substring boost: if the normalised query is a substring,
|
||||
# treat that as 1.0 regardless of WRatio.
|
||||
if q_norm in p_norm:
|
||||
scored.append((p, 1.0))
|
||||
continue
|
||||
score = svc.similarity(q_norm, p_norm)
|
||||
if score >= 0.50: # surfacing threshold; not the match threshold
|
||||
scored.append((p, score))
|
||||
|
||||
# Sort: score desc, then name asc.
|
||||
scored.sort(key=lambda t: (-t[1], t[0].name.lower()))
|
||||
scored = scored[:limit]
|
||||
|
||||
# Compute location counts in one batch query.
|
||||
loc_counts: dict[str, int] = {}
|
||||
if scored:
|
||||
from sqlalchemy import func
|
||||
ids = [p.id for p, _ in scored]
|
||||
rows = (
|
||||
db.query(MonitoringLocation.project_id, func.count(MonitoringLocation.id))
|
||||
.filter(MonitoringLocation.project_id.in_(ids))
|
||||
.group_by(MonitoringLocation.project_id)
|
||||
.all()
|
||||
)
|
||||
loc_counts = {pid: cnt for pid, cnt in rows}
|
||||
|
||||
return {
|
||||
"matches": [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"project_number": p.project_number,
|
||||
"client_name": p.client_name,
|
||||
"score": round(score, 3),
|
||||
"location_count": loc_counts.get(p.id, 0),
|
||||
}
|
||||
for p, score in scored
|
||||
],
|
||||
"create_new": {"label": f'Create new: "{q_clean}"' if q_clean else None},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/locations_search")
|
||||
def locations_search(
|
||||
project_id: str,
|
||||
q: str = "",
|
||||
limit: int = 10,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Typeahead search of existing locations within a project."""
|
||||
if not project_id:
|
||||
raise HTTPException(status_code=400, detail="project_id required")
|
||||
|
||||
q_clean = (q or "").strip()
|
||||
q_norm = svc._normalise(q_clean)
|
||||
|
||||
locations = (
|
||||
db.query(MonitoringLocation)
|
||||
.filter(MonitoringLocation.project_id == project_id)
|
||||
.filter(MonitoringLocation.location_type == "vibration")
|
||||
.all()
|
||||
)
|
||||
|
||||
scored: list[tuple[MonitoringLocation, float]] = []
|
||||
for l in locations:
|
||||
l_norm = svc._normalise(l.name)
|
||||
if not q_norm:
|
||||
scored.append((l, 0.0))
|
||||
continue
|
||||
if q_norm in l_norm:
|
||||
scored.append((l, 1.0))
|
||||
continue
|
||||
score = svc.similarity(q_norm, l_norm)
|
||||
if score >= 0.50:
|
||||
scored.append((l, score))
|
||||
|
||||
scored.sort(key=lambda t: (-t[1], t[0].name.lower()))
|
||||
scored = scored[:limit]
|
||||
|
||||
return {
|
||||
"matches": [
|
||||
{
|
||||
"id": l.id,
|
||||
"name": l.name,
|
||||
"address": l.address,
|
||||
"score": round(score, 3),
|
||||
}
|
||||
for l, score in scored
|
||||
],
|
||||
"create_new": {"label": f'Create new: "{q_clean}"' if q_clean else None},
|
||||
}
|
||||
@@ -24,17 +24,74 @@ from backend.database import get_db
|
||||
from backend.models import (
|
||||
Project,
|
||||
ProjectType,
|
||||
ProjectModule,
|
||||
MonitoringLocation,
|
||||
UnitAssignment,
|
||||
RosterUnit,
|
||||
MonitoringSession,
|
||||
DataFile,
|
||||
UnitHistory,
|
||||
)
|
||||
from backend.templates_config import templates
|
||||
from backend.utils.timezone import local_to_utc
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||||
|
||||
|
||||
# ── Audit log helper ──────────────────────────────────────────────────────────
|
||||
# Mirrors record_history() in roster_edit.py. Kept local to avoid cross-router
|
||||
# imports. The four assignment endpoints below use this to write UnitHistory
|
||||
# rows that the unit-detail deployment timeline (Phase 4) renders.
|
||||
|
||||
def _record_assignment_history(
|
||||
db: Session,
|
||||
unit_id: str,
|
||||
change_type: str,
|
||||
*,
|
||||
old_value: Optional[str] = None,
|
||||
new_value: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Append a UnitHistory row for an assignment-lifecycle event.
|
||||
|
||||
change_type values used:
|
||||
- assignment_created — unit assigned to a location (new assignment)
|
||||
- assignment_ended — unit unassigned / removed (assigned_until set)
|
||||
- assignment_swapped — unit replaced by another at the same location
|
||||
- assignment_updated — assignment dates / notes edited via PATCH
|
||||
|
||||
Caller is responsible for db.commit().
|
||||
"""
|
||||
db.add(UnitHistory(
|
||||
unit_id=unit_id,
|
||||
change_type=change_type,
|
||||
field_name="unit_assignment",
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
changed_at=datetime.utcnow(),
|
||||
source="manual",
|
||||
notes=notes,
|
||||
))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Shared helpers
|
||||
# ============================================================================
|
||||
|
||||
def _require_module(project, module_type: str, db: Session) -> None:
|
||||
"""Raise 400 if the project does not have the given module enabled."""
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found.")
|
||||
exists = db.query(ProjectModule).filter_by(
|
||||
project_id=project.id, module_type=module_type, enabled=True
|
||||
).first()
|
||||
if not exists:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"This project does not have the {module_type.replace('_', ' ').title()} module enabled.",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Session period helpers
|
||||
# ============================================================================
|
||||
@@ -98,11 +155,11 @@ async def get_project_locations(
|
||||
# Enrich with assignment info
|
||||
locations_data = []
|
||||
for location in locations:
|
||||
# Get active assignment
|
||||
# Get active assignment (active = assigned_until IS NULL)
|
||||
assignment = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == location.id,
|
||||
UnitAssignment.status == "active",
|
||||
UnitAssignment.assigned_until == None,
|
||||
)
|
||||
).first()
|
||||
|
||||
@@ -258,11 +315,11 @@ async def delete_location(
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
# Check if location has active assignments
|
||||
# Check if location has active assignments (active = assigned_until IS NULL)
|
||||
active_assignments = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == location_id,
|
||||
UnitAssignment.status == "active",
|
||||
UnitAssignment.assigned_until == None,
|
||||
)
|
||||
).count()
|
||||
|
||||
@@ -353,18 +410,18 @@ async def assign_unit_to_location(
|
||||
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
||||
)
|
||||
|
||||
# Check if location already has an active assignment
|
||||
# Check if location already has an active assignment (active = assigned_until IS NULL)
|
||||
existing_assignment = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == location_id,
|
||||
UnitAssignment.status == "active",
|
||||
UnitAssignment.assigned_until == None,
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing_assignment:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Unassign first.",
|
||||
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.",
|
||||
)
|
||||
|
||||
# Create new assignment
|
||||
@@ -383,6 +440,13 @@ async def assign_unit_to_location(
|
||||
)
|
||||
|
||||
db.add(assignment)
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=unit_id,
|
||||
change_type="assignment_created",
|
||||
new_value=f"{location.name} (project: {location.project_id})",
|
||||
notes=form_data.get("notes"),
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
|
||||
@@ -428,15 +492,295 @@ async def unassign_unit(
|
||||
assignment.status = "completed"
|
||||
assignment.assigned_until = datetime.utcnow()
|
||||
|
||||
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=assignment.unit_id,
|
||||
change_type="assignment_ended",
|
||||
old_value=location.name if location else assignment.location_id,
|
||||
new_value="unassigned",
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"success": True, "message": "Unit unassigned successfully"}
|
||||
|
||||
|
||||
@router.patch("/assignments/{assignment_id}")
|
||||
async def update_assignment(
|
||||
project_id: str,
|
||||
assignment_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update an assignment's date window and/or notes.
|
||||
|
||||
Common use case: backdate a deployment so events emitted before the
|
||||
operator created the assignment in terra-view (e.g. a unit that was
|
||||
physically deployed in December but only recorded in the system today)
|
||||
get correctly attributed to the location.
|
||||
|
||||
Accepts JSON body with optional fields:
|
||||
- assigned_at: ISO datetime (or empty string to leave unchanged)
|
||||
- assigned_until: ISO datetime, or null/"" to mark indefinite (active)
|
||||
- notes: string
|
||||
|
||||
Sets `status` to "active" when assigned_until is cleared, "completed"
|
||||
when it's set in the past.
|
||||
"""
|
||||
assignment = db.query(UnitAssignment).filter_by(
|
||||
id=assignment_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
|
||||
# Parse new values (None = unchanged, explicit None/"" for assigned_until = clear)
|
||||
new_assigned_at = assignment.assigned_at
|
||||
new_assigned_until = assignment.assigned_until
|
||||
new_notes = assignment.notes
|
||||
|
||||
if "assigned_at" in payload:
|
||||
raw = payload["assigned_at"]
|
||||
if raw is None or raw == "":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="assigned_at is required; cannot be cleared.",
|
||||
)
|
||||
try:
|
||||
# Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
|
||||
new_assigned_at = datetime.fromisoformat(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid assigned_at datetime: {raw!r}",
|
||||
)
|
||||
|
||||
if "assigned_until" in payload:
|
||||
raw = payload["assigned_until"]
|
||||
if raw is None or raw == "":
|
||||
new_assigned_until = None
|
||||
else:
|
||||
try:
|
||||
new_assigned_until = datetime.fromisoformat(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid assigned_until datetime: {raw!r}",
|
||||
)
|
||||
|
||||
if "notes" in payload:
|
||||
raw = payload["notes"]
|
||||
new_notes = (raw or "").strip() or None
|
||||
|
||||
# Validation: end must be after start if both set.
|
||||
if new_assigned_until is not None and new_assigned_until <= new_assigned_at:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="assigned_until must be after assigned_at.",
|
||||
)
|
||||
|
||||
# Sanity: reject creating an overlap with another assignment of the SAME
|
||||
# unit at the SAME location. Different units at the same location can
|
||||
# legitimately overlap during a swap window (rare but valid).
|
||||
new_end_for_overlap = new_assigned_until or datetime.utcnow()
|
||||
overlapping = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.location_id == assignment.location_id)
|
||||
.filter(UnitAssignment.unit_id == assignment.unit_id)
|
||||
.filter(UnitAssignment.id != assignment.id)
|
||||
.all()
|
||||
)
|
||||
for other in overlapping:
|
||||
other_start = other.assigned_at
|
||||
other_end = other.assigned_until or datetime.utcnow()
|
||||
if new_assigned_at < other_end and new_end_for_overlap > other_start:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"This window overlaps with another assignment for the "
|
||||
f"same unit ({other.assigned_at:%Y-%m-%d} → "
|
||||
f"{other.assigned_until and other.assigned_until.strftime('%Y-%m-%d') or 'present'})."
|
||||
),
|
||||
)
|
||||
|
||||
# Capture change description for audit log BEFORE mutating.
|
||||
old_start = assignment.assigned_at.isoformat() if assignment.assigned_at else None
|
||||
old_end = assignment.assigned_until.isoformat() if assignment.assigned_until else "active"
|
||||
new_start = new_assigned_at.isoformat() if new_assigned_at else None
|
||||
new_end = new_assigned_until.isoformat() if new_assigned_until else "active"
|
||||
|
||||
# Apply.
|
||||
assignment.assigned_at = new_assigned_at
|
||||
assignment.assigned_until = new_assigned_until
|
||||
assignment.notes = new_notes
|
||||
assignment.status = "completed" if new_assigned_until is not None else "active"
|
||||
|
||||
if old_start != new_start or old_end != new_end:
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=assignment.unit_id,
|
||||
change_type="assignment_updated",
|
||||
old_value=f"{old_start} → {old_end}",
|
||||
new_value=f"{new_start} → {new_end}",
|
||||
notes=new_notes,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"assignment": {
|
||||
"id": assignment.id,
|
||||
"unit_id": assignment.unit_id,
|
||||
"location_id": assignment.location_id,
|
||||
"assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
|
||||
"assigned_until": assignment.assigned_until.isoformat() if assignment.assigned_until else None,
|
||||
"status": assignment.status,
|
||||
"notes": assignment.notes,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/locations/{location_id}/swap")
|
||||
async def swap_unit_on_location(
|
||||
project_id: str,
|
||||
location_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Swap the unit assigned to a vibration monitoring location.
|
||||
Ends the current active assignment (if any), creates a new one,
|
||||
and optionally updates modem pairing on the seismograph.
|
||||
Works for first-time assignments too (no current assignment = just create).
|
||||
"""
|
||||
location = db.query(MonitoringLocation).filter_by(
|
||||
id=location_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
form_data = await request.form()
|
||||
unit_id = form_data.get("unit_id")
|
||||
modem_id = form_data.get("modem_id") or None
|
||||
notes = form_data.get("notes") or None
|
||||
|
||||
if not unit_id:
|
||||
raise HTTPException(status_code=400, detail="unit_id is required")
|
||||
|
||||
# Validate new unit
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
|
||||
expected_device_type = "slm" if location.location_type == "sound" else "seismograph"
|
||||
if unit.device_type != expected_device_type:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
||||
)
|
||||
|
||||
# End current active assignment if one exists (active = assigned_until IS NULL)
|
||||
current = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == location_id,
|
||||
UnitAssignment.assigned_until == None,
|
||||
)
|
||||
).first()
|
||||
if current:
|
||||
current.assigned_until = datetime.utcnow()
|
||||
current.status = "completed"
|
||||
# If the swap is replacing a different unit, that unit's deployment ended.
|
||||
if current.unit_id != unit_id:
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=current.unit_id,
|
||||
change_type="assignment_swapped",
|
||||
old_value=location.name,
|
||||
new_value=f"swapped out → {unit_id}",
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# Create new assignment
|
||||
new_assignment = UnitAssignment(
|
||||
id=str(uuid.uuid4()),
|
||||
unit_id=unit_id,
|
||||
location_id=location_id,
|
||||
project_id=project_id,
|
||||
device_type=unit.device_type,
|
||||
assigned_until=None,
|
||||
status="active",
|
||||
notes=notes,
|
||||
)
|
||||
db.add(new_assignment)
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=unit_id,
|
||||
change_type="assignment_swapped" if (current and current.unit_id != unit_id) else "assignment_created",
|
||||
new_value=f"{location.name} (project: {location.project_id})",
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# Update modem pairing on the seismograph if modem provided
|
||||
if modem_id:
|
||||
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||
if not modem:
|
||||
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
|
||||
unit.deployed_with_modem_id = modem_id
|
||||
modem.deployed_with_unit_id = unit_id
|
||||
else:
|
||||
# Clear modem pairing if not provided
|
||||
unit.deployed_with_modem_id = None
|
||||
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"assignment_id": new_assignment.id,
|
||||
"message": f"Unit '{unit_id}' assigned to '{location.name}'" + (f" with modem '{modem_id}'" if modem_id else ""),
|
||||
})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Available Units for Assignment
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/available-modems", response_class=JSONResponse)
|
||||
async def get_available_modems(
|
||||
project_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get all deployed, non-retired modems for the modem assignment dropdown.
|
||||
"""
|
||||
modems = db.query(RosterUnit).filter(
|
||||
and_(
|
||||
RosterUnit.device_type == "modem",
|
||||
RosterUnit.deployed == True,
|
||||
RosterUnit.retired == False,
|
||||
)
|
||||
).order_by(RosterUnit.id).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"hardware_model": m.hardware_model,
|
||||
"ip_address": m.ip_address,
|
||||
}
|
||||
for m in modems
|
||||
]
|
||||
|
||||
|
||||
@router.get("/available-units", response_class=JSONResponse)
|
||||
async def get_available_units(
|
||||
project_id: str,
|
||||
@@ -459,9 +803,9 @@ async def get_available_units(
|
||||
)
|
||||
).all()
|
||||
|
||||
# Filter out units that already have active assignments
|
||||
# Filter out units that already have active assignments (active = assigned_until IS NULL)
|
||||
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
||||
UnitAssignment.status == "active"
|
||||
UnitAssignment.assigned_until == None
|
||||
).distinct().all()
|
||||
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
||||
|
||||
@@ -518,6 +862,108 @@ async def get_nrl_sessions(
|
||||
})
|
||||
|
||||
|
||||
@router.get("/vibration_summary", response_class=HTMLResponse)
|
||||
async def get_project_vibration_summary(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
from_dt: Optional[datetime] = Query(None),
|
||||
to_dt: Optional[datetime] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render a small HTML partial summarising vibration-event activity
|
||||
across every vibration MonitoringLocation in the project.
|
||||
|
||||
Returned to the Vibration tab of the project detail page via HTMX.
|
||||
Fans out concurrently across all locations (which in turn fan out
|
||||
across each location's UnitAssignment windows). Total queries to
|
||||
SFM = sum of assignments across the project.
|
||||
|
||||
404 if the project doesn't exist. Empty-state partial if the
|
||||
project has no vibration locations.
|
||||
"""
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found.")
|
||||
|
||||
from backend.services.sfm_events import vibration_summary_for_project
|
||||
|
||||
summary = await vibration_summary_for_project(
|
||||
db, project_id, from_dt=from_dt, to_dt=to_dt
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/projects/vibration_summary.html",
|
||||
{
|
||||
"request": request,
|
||||
"project_id": project_id,
|
||||
"summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/locations/{location_id}/events", response_class=JSONResponse)
|
||||
async def get_location_events(
|
||||
project_id: str,
|
||||
location_id: str,
|
||||
from_dt: Optional[datetime] = Query(None),
|
||||
to_dt: Optional[datetime] = Query(None),
|
||||
false_trigger: Optional[bool] = Query(None),
|
||||
limit: int = Query(500, ge=1, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Return SFM events recorded at this monitoring location.
|
||||
|
||||
Fans out the location's UnitAssignment rows (every seismograph ever
|
||||
assigned to this location, active + closed), queries SFM /db/events
|
||||
for each (serial, time-window) pair concurrently, and unions the
|
||||
results.
|
||||
|
||||
Sound (SLM) locations return an empty payload — SFM events are
|
||||
seismograph-only.
|
||||
"""
|
||||
location = db.query(MonitoringLocation).filter_by(id=location_id).first()
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found.")
|
||||
if location.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Location does not belong to this project.",
|
||||
)
|
||||
|
||||
# SLM locations don't have SFM events — return an empty payload rather
|
||||
# than 404 so the frontend can render an empty state gracefully.
|
||||
if location.location_type != "vibration":
|
||||
return {
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"stats": {
|
||||
"event_count": 0,
|
||||
"peak_pvs": None,
|
||||
"peak_pvs_at": None,
|
||||
"peak_pvs_serial": None,
|
||||
"last_event": None,
|
||||
"false_trigger_count": 0,
|
||||
},
|
||||
"assignments_used": [],
|
||||
"location_type": location.location_type,
|
||||
}
|
||||
|
||||
from backend.services.sfm_events import events_for_location
|
||||
|
||||
result = await events_for_location(
|
||||
db,
|
||||
location_id,
|
||||
from_dt=from_dt,
|
||||
to_dt=to_dt,
|
||||
false_trigger=false_trigger,
|
||||
limit=limit,
|
||||
)
|
||||
result["location_type"] = location.location_type
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/nrl/{location_id}/files", response_class=HTMLResponse)
|
||||
async def get_nrl_files(
|
||||
project_id: str,
|
||||
@@ -637,6 +1083,9 @@ async def upload_nrl_data(
|
||||
from datetime import datetime
|
||||
|
||||
# Verify project and location exist
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
_require_module(project, "sound_monitoring", db)
|
||||
|
||||
location = db.query(MonitoringLocation).filter_by(
|
||||
id=location_id, project_id=project_id
|
||||
).first()
|
||||
@@ -698,8 +1147,15 @@ async def upload_nrl_data(
|
||||
rnh_meta = _parse_rnh(fbytes)
|
||||
break
|
||||
|
||||
started_at = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
||||
stopped_at = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
||||
# RNH files store local time (no UTC offset). Use local values for period
|
||||
# classification / label generation, then convert to UTC for DB storage so
|
||||
# the local_datetime Jinja filter displays the correct time.
|
||||
started_at_local = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
||||
stopped_at_local = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
||||
|
||||
started_at = local_to_utc(started_at_local)
|
||||
stopped_at = local_to_utc(stopped_at_local) if stopped_at_local else None
|
||||
|
||||
duration_seconds = None
|
||||
if started_at and stopped_at:
|
||||
duration_seconds = int((stopped_at - started_at).total_seconds())
|
||||
@@ -709,8 +1165,9 @@ async def upload_nrl_data(
|
||||
index_number = rnh_meta.get("index_number", "")
|
||||
|
||||
# --- Step 3: Create MonitoringSession ---
|
||||
period_type = _derive_period_type(started_at) if started_at else None
|
||||
session_label = _build_session_label(started_at, location.name, period_type) if started_at else None
|
||||
# Use local times for period/label so classification reflects the clock at the site.
|
||||
period_type = _derive_period_type(started_at_local) if started_at_local else None
|
||||
session_label = _build_session_label(started_at_local, location.name, period_type) if started_at_local else None
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
monitoring_session = MonitoringSession(
|
||||
@@ -815,15 +1272,18 @@ async def get_nrl_live_status(
|
||||
Fetch cached status from SLMM for the unit assigned to this NRL and
|
||||
return a compact HTML status card. Used in the NRL overview tab for
|
||||
connected NRLs. Gracefully shows an offline message if SLMM is unreachable.
|
||||
Sound Monitoring projects only.
|
||||
"""
|
||||
import os
|
||||
import httpx
|
||||
|
||||
# Find the assigned unit
|
||||
_require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db)
|
||||
|
||||
# Find the assigned unit (active = assigned_until IS NULL)
|
||||
assignment = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == location_id,
|
||||
UnitAssignment.status == "active",
|
||||
UnitAssignment.assigned_until == None,
|
||||
)
|
||||
).first()
|
||||
|
||||
|
||||
+672
-32
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,8 @@ import httpx
|
||||
import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences
|
||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
||||
import uuid
|
||||
from backend.services.slmm_sync import sync_slm_to_slmm
|
||||
|
||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||
@@ -27,6 +28,38 @@ def get_calibration_interval(db: Session) -> int:
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
|
||||
def sync_deployment_record(db: Session, unit: RosterUnit, new_deployed: bool):
|
||||
"""
|
||||
Keep DeploymentRecord in sync with the deployed flag.
|
||||
|
||||
deployed True → open a new DeploymentRecord if none is already open.
|
||||
deployed False → close the active DeploymentRecord by setting actual_removal_date = today.
|
||||
"""
|
||||
if new_deployed:
|
||||
existing = db.query(DeploymentRecord).filter(
|
||||
DeploymentRecord.unit_id == unit.id,
|
||||
DeploymentRecord.actual_removal_date == None
|
||||
).first()
|
||||
if not existing:
|
||||
record = DeploymentRecord(
|
||||
id=str(uuid.uuid4()),
|
||||
unit_id=unit.id,
|
||||
project_ref=unit.project_id or None,
|
||||
deployed_date=date.today(),
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(record)
|
||||
else:
|
||||
active = db.query(DeploymentRecord).filter(
|
||||
DeploymentRecord.unit_id == unit.id,
|
||||
DeploymentRecord.actual_removal_date == None
|
||||
).first()
|
||||
if active:
|
||||
active.actual_removal_date = date.today()
|
||||
active.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
||||
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
|
||||
"""Helper function to record a change in unit history"""
|
||||
@@ -467,6 +500,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
"deployed": unit.deployed,
|
||||
"retired": unit.retired,
|
||||
"out_for_calibration": unit.out_for_calibration or False,
|
||||
"allocated": getattr(unit, 'allocated', False) or False,
|
||||
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
|
||||
"note": unit.note or "",
|
||||
"project_id": unit.project_id or "",
|
||||
"location": unit.location or "",
|
||||
@@ -499,6 +534,8 @@ async def edit_roster_unit(
|
||||
deployed: str = Form(None),
|
||||
retired: str = Form(None),
|
||||
out_for_calibration: str = Form(None),
|
||||
allocated: str = Form(None),
|
||||
allocated_to_project_id: str = Form(None),
|
||||
note: str = Form(""),
|
||||
project_id: str = Form(None),
|
||||
location: str = Form(None),
|
||||
@@ -541,6 +578,7 @@ async def edit_roster_unit(
|
||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||
out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False
|
||||
allocated_bool = allocated in ['true', 'True', '1', 'yes'] if allocated else False
|
||||
|
||||
# Convert port strings to integers
|
||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||
@@ -578,6 +616,8 @@ async def edit_roster_unit(
|
||||
unit.deployed = deployed_bool
|
||||
unit.retired = retired_bool
|
||||
unit.out_for_calibration = out_for_calibration_bool
|
||||
unit.allocated = allocated_bool
|
||||
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
|
||||
unit.note = note
|
||||
unit.project_id = project_id
|
||||
unit.location = location
|
||||
@@ -679,6 +719,7 @@ async def edit_roster_unit(
|
||||
status_text = "deployed" if deployed else "benched"
|
||||
old_status_text = "deployed" if old_deployed else "benched"
|
||||
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
|
||||
sync_deployment_record(db, unit, deployed_bool)
|
||||
|
||||
if old_retired != retired:
|
||||
status_text = "retired" if retired else "active"
|
||||
@@ -795,6 +836,7 @@ async def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = D
|
||||
new_value=status_text,
|
||||
source="manual"
|
||||
)
|
||||
sync_deployment_record(db, unit, deployed)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ Seismograph Dashboard API Router
|
||||
Provides endpoints for the seismograph-specific dashboard
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, Query
|
||||
from fastapi import APIRouter, Request, Depends, Query, Form, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
from backend.models import RosterUnit, UnitHistory, UserPreferences
|
||||
from backend.templates_config import templates
|
||||
|
||||
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
||||
@@ -120,3 +120,109 @@ async def get_seismo_units(
|
||||
"today": date.today()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _get_calibration_interval(db: Session) -> int:
|
||||
prefs = db.query(UserPreferences).first()
|
||||
if prefs and prefs.calibration_interval_days:
|
||||
return prefs.calibration_interval_days
|
||||
return 365
|
||||
|
||||
|
||||
def _row_context(request: Request, unit: RosterUnit) -> dict:
|
||||
return {"request": request, "unit": unit, "today": date.today()}
|
||||
|
||||
|
||||
@router.get("/unit/{unit_id}/view-row", response_class=HTMLResponse)
|
||||
async def get_seismo_view_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))
|
||||
|
||||
|
||||
@router.get("/unit/{unit_id}/edit-row", response_class=HTMLResponse)
|
||||
async def get_seismo_edit_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
return templates.TemplateResponse("partials/seismo_row_edit.html", _row_context(request, unit))
|
||||
|
||||
|
||||
@router.post("/unit/{unit_id}/quick-update", response_class=HTMLResponse)
|
||||
async def quick_update_seismo_unit(
|
||||
unit_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
status: str = Form(...),
|
||||
last_calibrated: str = Form(""),
|
||||
note: str = Form(""),
|
||||
):
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
|
||||
# --- Status ---
|
||||
old_deployed = unit.deployed
|
||||
old_out_for_cal = unit.out_for_calibration
|
||||
if status == "deployed":
|
||||
unit.deployed = True
|
||||
unit.out_for_calibration = False
|
||||
elif status == "out_for_calibration":
|
||||
unit.deployed = False
|
||||
unit.out_for_calibration = True
|
||||
else:
|
||||
unit.deployed = False
|
||||
unit.out_for_calibration = False
|
||||
|
||||
if unit.deployed != old_deployed or unit.out_for_calibration != old_out_for_cal:
|
||||
old_status = "deployed" if old_deployed else ("out_for_calibration" if old_out_for_cal else "benched")
|
||||
db.add(UnitHistory(
|
||||
unit_id=unit_id,
|
||||
change_type="deployed_change",
|
||||
field_name="status",
|
||||
old_value=old_status,
|
||||
new_value=status,
|
||||
source="manual",
|
||||
))
|
||||
|
||||
# --- Last calibrated ---
|
||||
old_cal = unit.last_calibrated
|
||||
if last_calibrated:
|
||||
try:
|
||||
new_cal = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
unit.last_calibrated = new_cal
|
||||
unit.next_calibration_due = new_cal + timedelta(days=_get_calibration_interval(db))
|
||||
else:
|
||||
unit.last_calibrated = None
|
||||
unit.next_calibration_due = None
|
||||
|
||||
if unit.last_calibrated != old_cal:
|
||||
db.add(UnitHistory(
|
||||
unit_id=unit_id,
|
||||
change_type="calibration_status_change",
|
||||
field_name="last_calibrated",
|
||||
old_value=old_cal.strftime("%Y-%m-%d") if old_cal else None,
|
||||
new_value=last_calibrated or None,
|
||||
source="manual",
|
||||
))
|
||||
|
||||
# --- Note ---
|
||||
old_note = unit.note
|
||||
unit.note = note or None
|
||||
if unit.note != old_note:
|
||||
db.add(UnitHistory(
|
||||
unit_id=unit_id,
|
||||
change_type="note_change",
|
||||
field_name="note",
|
||||
old_value=old_note,
|
||||
new_value=unit.note,
|
||||
source="manual",
|
||||
))
|
||||
|
||||
db.commit()
|
||||
db.refresh(unit)
|
||||
|
||||
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
SFM (Seismograph Field Module) Proxy Router
|
||||
|
||||
Proxies requests from terra-view to the standalone SFM backend service.
|
||||
SFM runs on port 8200 and handles MiniMate Plus seismograph communication
|
||||
and event database queries.
|
||||
|
||||
SFM endpoints are at root level (e.g. /db/units, /device/info) — no /api/ prefix.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
import httpx
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/sfm", tags=["sfm"])
|
||||
|
||||
# SFM backend URL - configurable via environment variable
|
||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def check_sfm_health():
|
||||
"""
|
||||
Check if the SFM backend service is reachable and healthy.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{SFM_BASE_URL}/health")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
"status": "ok",
|
||||
"sfm_status": "connected",
|
||||
"sfm_url": SFM_BASE_URL,
|
||||
"sfm_response": data
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "degraded",
|
||||
"sfm_status": "error",
|
||||
"sfm_url": SFM_BASE_URL,
|
||||
"detail": f"SFM returned status {response.status_code}"
|
||||
}
|
||||
|
||||
except httpx.ConnectError:
|
||||
return {
|
||||
"status": "error",
|
||||
"sfm_status": "unreachable",
|
||||
"sfm_url": SFM_BASE_URL,
|
||||
"detail": "Cannot connect to SFM backend. Is it running?"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"sfm_status": "error",
|
||||
"sfm_url": SFM_BASE_URL,
|
||||
"detail": str(e)
|
||||
}
|
||||
|
||||
|
||||
# HTTP catch-all — proxies everything else to SFM backend
|
||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
async def proxy_to_sfm(path: str, request: Request):
|
||||
"""
|
||||
Proxy all requests to the SFM backend service.
|
||||
|
||||
SFM endpoints have no /api/ prefix — target URL is {SFM_BASE_URL}/{path}.
|
||||
Timeout is 60s to allow for live device round-trips (event downloads can
|
||||
take 30-45s for a full event list).
|
||||
"""
|
||||
# Build target URL — SFM endpoints live at root, not /api/
|
||||
target_url = f"{SFM_BASE_URL}/{path}"
|
||||
|
||||
# Forward query params
|
||||
query_params = dict(request.query_params)
|
||||
|
||||
# Read body for mutation requests
|
||||
body = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
try:
|
||||
body = await request.body()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read request body: {e}")
|
||||
body = None
|
||||
|
||||
# Strip hop-by-hop headers
|
||||
headers = dict(request.headers)
|
||||
headers_to_exclude = ["host", "content-length", "transfer-encoding", "connection"]
|
||||
proxy_headers = {k: v for k, v in headers.items() if k.lower() not in headers_to_exclude}
|
||||
|
||||
logger.info(f"Proxying {request.method} {path} → SFM: {target_url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
params=query_params,
|
||||
headers=proxy_headers,
|
||||
content=body
|
||||
)
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.headers.get("content-type")
|
||||
)
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.error(f"Failed to connect to SFM backend at {SFM_BASE_URL}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"SFM backend service unavailable. Is SFM running on {SFM_BASE_URL}?"
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"Timeout connecting to SFM backend at {SFM_BASE_URL}")
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="SFM backend timeout"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error proxying to SFM: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to proxy request to SFM: {str(e)}"
|
||||
)
|
||||
+100
-2
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
@@ -72,3 +72,101 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
||||
"slm_serial_number": unit.slm_serial_number,
|
||||
"deployed_with_modem_id": unit.deployed_with_modem_id
|
||||
}
|
||||
|
||||
|
||||
@router.get("/units/{unit_id}/events")
|
||||
async def get_unit_events(
|
||||
unit_id: str,
|
||||
bucket: str = Query("all", regex="^(all|attributed|unattributed)$"),
|
||||
from_dt: Optional[datetime] = Query(None),
|
||||
to_dt: Optional[datetime] = Query(None),
|
||||
false_trigger: Optional[bool] = Query(None),
|
||||
limit: int = Query(500, ge=1, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Return SFM events for a single unit, annotated with assignment attribution.
|
||||
|
||||
Each event includes an `attribution` object pointing at the project/location
|
||||
it falls into (or null if outside every assignment window). Unattributed
|
||||
events also carry a `nearest_assignment` field with `delta_days` so the
|
||||
operator can see how far off the nearest assignment is — useful for
|
||||
deciding whether to backdate the assignment to absorb the event.
|
||||
|
||||
Bucket filter:
|
||||
- all (default): every event
|
||||
- attributed: only events inside an assignment window
|
||||
- unattributed: only orphan events (the diagnostic bucket)
|
||||
|
||||
Non-seismograph units return an empty events list. The route does not
|
||||
404 for SLMs/modems so the unit detail page can render the section
|
||||
conditionally without depending on the response shape.
|
||||
"""
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||
|
||||
if unit.device_type != "seismograph":
|
||||
return {
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"stats": {
|
||||
"event_count": 0,
|
||||
"unattributed_count": 0,
|
||||
"peak_pvs": None,
|
||||
"peak_pvs_at": None,
|
||||
"peak_pvs_serial": None,
|
||||
"last_event": None,
|
||||
"false_trigger_count": 0,
|
||||
},
|
||||
"assignments_total": 0,
|
||||
"device_type": unit.device_type,
|
||||
}
|
||||
|
||||
from backend.services.sfm_events import events_for_unit
|
||||
|
||||
result = await events_for_unit(
|
||||
db,
|
||||
unit_id,
|
||||
bucket=bucket,
|
||||
from_dt=from_dt,
|
||||
to_dt=to_dt,
|
||||
false_trigger=false_trigger,
|
||||
limit=limit,
|
||||
)
|
||||
result["device_type"] = unit.device_type
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/units/{unit_id}/deployment_timeline")
|
||||
async def get_unit_deployment_timeline(
|
||||
unit_id: str,
|
||||
include_events: bool = Query(True),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Return a chronological deployment timeline for a unit.
|
||||
|
||||
Merges three sources:
|
||||
1. unit_assignments — authoritative project/location deployments
|
||||
2. unit_history — state changes (calibration, retirement, etc.)
|
||||
3. SFM events — per-assignment overlay (count + peak PVS + last event)
|
||||
|
||||
Replaces the legacy /api/deployments/{unit_id} (which read the
|
||||
deprecated `deployment_records` table) and the
|
||||
/api/roster/history/{unit_id} timeline endpoint, unifying them into
|
||||
a single derived view.
|
||||
|
||||
Gaps >= 1 day between consecutive assignments are surfaced as
|
||||
synthetic "gap" entries.
|
||||
|
||||
Pass include_events=false to skip the SFM event overlay (saves N
|
||||
HTTP calls; useful for fast text-only history dumps).
|
||||
"""
|
||||
from backend.services.deployment_timeline import deployment_timeline_for_unit
|
||||
|
||||
return await deployment_timeline_for_unit(
|
||||
db,
|
||||
unit_id,
|
||||
include_event_overlay=include_events,
|
||||
)
|
||||
|
||||
+2
-1
@@ -267,7 +267,8 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
payload = await request.json()
|
||||
|
||||
source = payload.get("source", "series4_emitter")
|
||||
# Accept source_id (new standard field) with fallback to legacy "source" key
|
||||
source = payload.get("source_id") or payload.get("source", "series4_emitter")
|
||||
units = payload.get("units", [])
|
||||
version = payload.get("version")
|
||||
log_tail = payload.get("log_tail")
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Deployment timeline service — replaces the legacy `deployment_records`-driven
|
||||
timeline on the seismograph unit detail page.
|
||||
|
||||
Architecture:
|
||||
- `unit_assignments` is the authoritative source for "where was this unit"
|
||||
(one row per location/time-window). Auto-written by the project location
|
||||
swap/assign/unassign/update workflows.
|
||||
- `unit_history` is the audit log for non-location state changes
|
||||
(calibration toggles, retirement, allocation, etc.).
|
||||
- SFM events are overlaid per assignment window to show "what was the unit
|
||||
actually doing during this deployment" (count + peak PVS + last-event).
|
||||
|
||||
Gaps between assignments are emitted as synthetic "gap" entries so operators
|
||||
can see when the unit was idle vs out-of-service.
|
||||
|
||||
`deployment_records` is being deprecated; this module does not read it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import (
|
||||
UnitAssignment,
|
||||
UnitHistory,
|
||||
MonitoringLocation,
|
||||
Project,
|
||||
RosterUnit,
|
||||
)
|
||||
from backend.services.sfm_events import (
|
||||
SFM_BASE_URL,
|
||||
_fetch_events_for_serial,
|
||||
_iso_utc,
|
||||
)
|
||||
|
||||
log = logging.getLogger("backend.services.deployment_timeline")
|
||||
|
||||
# Don't emit synthetic gap entries shorter than this (seconds). Avoids visual
|
||||
# clutter from a sub-second handoff during a swap workflow.
|
||||
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
|
||||
|
||||
# Per-call timeout when querying SFM for the event overlay.
|
||||
_SFM_TIMEOUT = 10.0
|
||||
_SFM_FETCH_CEILING = 5000
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def deployment_timeline_for_unit(
|
||||
db: Session,
|
||||
unit_id: str,
|
||||
*,
|
||||
include_event_overlay: bool = True,
|
||||
) -> dict:
|
||||
"""Build a chronological timeline for a unit.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"unit_id": str,
|
||||
"device_type": str,
|
||||
"entries": [
|
||||
{
|
||||
"kind": "assignment" | "gap" | "state_change",
|
||||
"starts_at": ISO timestamp,
|
||||
"ends_at": ISO timestamp | None,
|
||||
"duration_days": float | None,
|
||||
# — assignment-only fields —
|
||||
"assignment_id": str,
|
||||
"location_id": str,
|
||||
"location_name": str,
|
||||
"project_id": str,
|
||||
"project_name": str,
|
||||
"is_active": bool,
|
||||
"event_overlay": {event_count, peak_pvs, peak_pvs_at, last_event}
|
||||
or None if include_event_overlay=False,
|
||||
"notes": str | None,
|
||||
# — gap-only fields —
|
||||
"context": "between assignments" | None,
|
||||
# — state_change-only fields —
|
||||
"change_type": str,
|
||||
"field_name": str | None,
|
||||
"old_value": str | None,
|
||||
"new_value": str | None,
|
||||
"source": str,
|
||||
"history_notes": str | None,
|
||||
},
|
||||
... # newest first
|
||||
],
|
||||
}
|
||||
"""
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit:
|
||||
return {"unit_id": unit_id, "device_type": None, "entries": []}
|
||||
|
||||
# 1. Load assignments + their location/project lookups in bulk.
|
||||
assignments = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.unit_id == unit_id)
|
||||
.order_by(UnitAssignment.assigned_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
loc_ids = {a.location_id for a in assignments}
|
||||
proj_ids = {a.project_id for a in assignments}
|
||||
loc_map = {
|
||||
l.id: l for l in db.query(MonitoringLocation).filter(
|
||||
MonitoringLocation.id.in_(loc_ids)
|
||||
).all()
|
||||
} if loc_ids else {}
|
||||
proj_map = {
|
||||
p.id: p for p in db.query(Project).filter(
|
||||
Project.id.in_(proj_ids)
|
||||
).all()
|
||||
} if proj_ids else {}
|
||||
|
||||
# 2. Load relevant unit_history rows. We surface state changes that
|
||||
# operators care about on a deployment timeline: calibration status,
|
||||
# retirement, deployed flag, allocation, calibration date, and the
|
||||
# assignment_* events we just added (those are redundant with the
|
||||
# assignment rows themselves, so we skip them to avoid double-rendering).
|
||||
interesting_change_types = (
|
||||
"calibration_status_change",
|
||||
"retired_change",
|
||||
"deployed_change",
|
||||
"allocation_change",
|
||||
"last_calibrated_change",
|
||||
"next_calibration_due_change",
|
||||
)
|
||||
history = (
|
||||
db.query(UnitHistory)
|
||||
.filter(UnitHistory.unit_id == unit_id)
|
||||
.filter(UnitHistory.change_type.in_(interesting_change_types))
|
||||
.order_by(UnitHistory.changed_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# 3. Optionally fetch SFM event overlay for each assignment window.
|
||||
# Concurrent fan-out via httpx + asyncio.gather.
|
||||
overlays: dict[str, dict] = {}
|
||||
if include_event_overlay and assignments and unit.device_type == "seismograph":
|
||||
async with httpx.AsyncClient(timeout=_SFM_TIMEOUT) as client:
|
||||
results = await asyncio.gather(
|
||||
*(
|
||||
_fetch_events_for_serial(
|
||||
client,
|
||||
serial=unit_id,
|
||||
from_dt=a.assigned_at,
|
||||
to_dt=a.assigned_until or now,
|
||||
false_trigger=None,
|
||||
limit=_SFM_FETCH_CEILING,
|
||||
)
|
||||
for a in assignments
|
||||
),
|
||||
return_exceptions=False,
|
||||
)
|
||||
for a, events in zip(assignments, results):
|
||||
peak = None
|
||||
peak_at = None
|
||||
last_ev = None
|
||||
for ev in events:
|
||||
pvs = ev.get("peak_vector_sum")
|
||||
if pvs is not None and (peak is None or pvs > peak):
|
||||
peak = pvs
|
||||
peak_at = ev.get("timestamp")
|
||||
ts = ev.get("timestamp")
|
||||
if ts and (last_ev is None or ts > last_ev):
|
||||
last_ev = ts
|
||||
overlays[a.id] = {
|
||||
"event_count": len(events),
|
||||
"peak_pvs": peak,
|
||||
"peak_pvs_at": peak_at,
|
||||
"last_event": last_ev,
|
||||
}
|
||||
|
||||
# 4. Build entries. Start by emitting assignment rows + gap rows between
|
||||
# consecutive assignments, then add state-change rows from unit_history.
|
||||
entries: list[dict] = []
|
||||
|
||||
for idx, a in enumerate(assignments):
|
||||
loc = loc_map.get(a.location_id)
|
||||
proj = proj_map.get(a.project_id)
|
||||
is_active = a.assigned_until is None
|
||||
ends_at = a.assigned_until or now
|
||||
duration_days = (ends_at - a.assigned_at).total_seconds() / 86400 if a.assigned_at else None
|
||||
|
||||
entry = {
|
||||
"kind": "assignment",
|
||||
"starts_at": _iso_utc(a.assigned_at),
|
||||
"ends_at": _iso_utc(a.assigned_until),
|
||||
"duration_days": round(duration_days, 1) if duration_days is not None else None,
|
||||
"assignment_id": a.id,
|
||||
"location_id": a.location_id,
|
||||
"location_name": loc.name if loc else None,
|
||||
"project_id": a.project_id,
|
||||
"project_name": proj.name if proj else None,
|
||||
"is_active": is_active,
|
||||
"notes": a.notes,
|
||||
"event_overlay": overlays.get(a.id),
|
||||
}
|
||||
entries.append(entry)
|
||||
|
||||
# Gap detection: from the end of this assignment to the start of the
|
||||
# next one. Only emit gaps that are at least _MIN_GAP_SECONDS long
|
||||
# so trivial sub-second handoffs during swaps don't clutter the view.
|
||||
if idx + 1 < len(assignments):
|
||||
next_a = assignments[idx + 1]
|
||||
gap_start = a.assigned_until or now
|
||||
gap_end = next_a.assigned_at
|
||||
gap_seconds = (gap_end - gap_start).total_seconds() if gap_end and gap_start else 0
|
||||
if gap_seconds >= _MIN_GAP_SECONDS:
|
||||
entries.append({
|
||||
"kind": "gap",
|
||||
"starts_at": _iso_utc(gap_start),
|
||||
"ends_at": _iso_utc(gap_end),
|
||||
"duration_days": round(gap_seconds / 86400, 1),
|
||||
"context": "between assignments",
|
||||
})
|
||||
|
||||
# 5. State changes — interleaved by timestamp. Skip no-op rows where
|
||||
# old_value == new_value (an artifact of the legacy record_history()
|
||||
# being called on every save regardless of whether the field changed).
|
||||
for h in history:
|
||||
if h.old_value == h.new_value:
|
||||
continue
|
||||
entries.append({
|
||||
"kind": "state_change",
|
||||
"starts_at": _iso_utc(h.changed_at),
|
||||
"ends_at": None,
|
||||
"duration_days": None,
|
||||
"change_type": h.change_type,
|
||||
"field_name": h.field_name,
|
||||
"old_value": h.old_value,
|
||||
"new_value": h.new_value,
|
||||
"source": h.source,
|
||||
"history_notes": h.notes,
|
||||
})
|
||||
|
||||
# 6. Sort newest first. Active assignments (no end) sort by start time,
|
||||
# same as everything else.
|
||||
entries.sort(key=lambda e: e.get("starts_at") or "", reverse=True)
|
||||
|
||||
return {
|
||||
"unit_id": unit.id,
|
||||
"device_type": unit.device_type,
|
||||
"entries": entries,
|
||||
}
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy import and_, or_
|
||||
|
||||
from backend.models import (
|
||||
RosterUnit, JobReservation, JobReservationUnit,
|
||||
UserPreferences, Project
|
||||
UserPreferences, Project, DeploymentRecord
|
||||
)
|
||||
|
||||
|
||||
@@ -70,6 +70,19 @@ def get_unit_reservations_on_date(
|
||||
return reservations
|
||||
|
||||
|
||||
def get_active_deployment(db: Session, unit_id: str) -> Optional[DeploymentRecord]:
|
||||
"""Return the active (unreturned) deployment record for a unit, or None."""
|
||||
return (
|
||||
db.query(DeploymentRecord)
|
||||
.filter(
|
||||
DeploymentRecord.unit_id == unit_id,
|
||||
DeploymentRecord.actual_removal_date == None
|
||||
)
|
||||
.order_by(DeploymentRecord.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def is_unit_available_on_date(
|
||||
db: Session,
|
||||
unit: RosterUnit,
|
||||
@@ -82,8 +95,8 @@ def is_unit_available_on_date(
|
||||
Returns:
|
||||
(is_available, status, reservation_name)
|
||||
- is_available: True if unit can be assigned to new work
|
||||
- status: "available", "reserved", "expired", "retired", "needs_calibration"
|
||||
- reservation_name: Name of blocking reservation (if any)
|
||||
- status: "available", "reserved", "expired", "retired", "needs_calibration", "in_field"
|
||||
- reservation_name: Name of blocking reservation or project ref (if any)
|
||||
"""
|
||||
# Check if retired
|
||||
if unit.retired:
|
||||
@@ -96,6 +109,12 @@ def is_unit_available_on_date(
|
||||
if cal_status == "needs_calibration":
|
||||
return False, "needs_calibration", None
|
||||
|
||||
# Check for an active deployment record (unit is physically in the field)
|
||||
active_deployment = get_active_deployment(db, unit.id)
|
||||
if active_deployment:
|
||||
label = active_deployment.project_ref or "Field deployment"
|
||||
return False, "in_field", label
|
||||
|
||||
# Check if already reserved
|
||||
reservations = get_unit_reservations_on_date(db, unit.id, check_date)
|
||||
if reservations:
|
||||
@@ -136,6 +155,7 @@ def get_day_summary(
|
||||
expired_units = []
|
||||
expiring_soon_units = []
|
||||
needs_calibration_units = []
|
||||
in_field_units = []
|
||||
cal_expiring_today = [] # Units whose calibration expires ON this day
|
||||
|
||||
for unit in units:
|
||||
@@ -167,6 +187,9 @@ def get_day_summary(
|
||||
available_units.append(unit_info)
|
||||
if cal_status == "expiring_soon":
|
||||
expiring_soon_units.append(unit_info)
|
||||
elif status == "in_field":
|
||||
unit_info["project_ref"] = reservation_name
|
||||
in_field_units.append(unit_info)
|
||||
elif status == "reserved":
|
||||
unit_info["reservation_name"] = reservation_name
|
||||
reserved_units.append(unit_info)
|
||||
@@ -207,6 +230,7 @@ def get_day_summary(
|
||||
"date": check_date.isoformat(),
|
||||
"device_type": device_type,
|
||||
"available_units": available_units,
|
||||
"in_field_units": in_field_units,
|
||||
"reserved_units": reserved_units,
|
||||
"expired_units": expired_units,
|
||||
"expiring_soon_units": expiring_soon_units,
|
||||
@@ -215,6 +239,7 @@ def get_day_summary(
|
||||
"reservations": reservation_list,
|
||||
"counts": {
|
||||
"available": len(available_units),
|
||||
"in_field": len(in_field_units),
|
||||
"reserved": len(reserved_units),
|
||||
"expired": len(expired_units),
|
||||
"expiring_soon": len(expiring_soon_units),
|
||||
@@ -285,6 +310,14 @@ def get_calendar_year_data(
|
||||
unit_reservations[unit_id] = []
|
||||
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
||||
|
||||
# Build set of unit IDs that have an active deployment record (still in the field)
|
||||
unit_ids = [u.id for u in units]
|
||||
active_deployments = db.query(DeploymentRecord.unit_id).filter(
|
||||
DeploymentRecord.unit_id.in_(unit_ids),
|
||||
DeploymentRecord.actual_removal_date == None
|
||||
).all()
|
||||
unit_in_field = {row.unit_id for row in active_deployments}
|
||||
|
||||
# Generate data for each month
|
||||
months_data = {}
|
||||
|
||||
@@ -301,6 +334,7 @@ def get_calendar_year_data(
|
||||
|
||||
while current_day <= last_day:
|
||||
available = 0
|
||||
in_field = 0
|
||||
reserved = 0
|
||||
expired = 0
|
||||
expiring_soon = 0
|
||||
@@ -328,6 +362,11 @@ def get_calendar_year_data(
|
||||
needs_cal += 1
|
||||
continue
|
||||
|
||||
# Check active deployment record (in field)
|
||||
if unit.id in unit_in_field:
|
||||
in_field += 1
|
||||
continue
|
||||
|
||||
# Check if reserved
|
||||
is_reserved = False
|
||||
if unit.id in unit_reservations:
|
||||
@@ -346,6 +385,7 @@ def get_calendar_year_data(
|
||||
|
||||
days_data[current_day.day] = {
|
||||
"available": available,
|
||||
"in_field": in_field,
|
||||
"reserved": reserved,
|
||||
"expired": expired,
|
||||
"expiring_soon": expiring_soon,
|
||||
@@ -462,6 +502,14 @@ def get_rolling_calendar_data(
|
||||
unit_reservations[unit_id] = []
|
||||
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
||||
|
||||
# Build set of unit IDs that have an active deployment record (still in the field)
|
||||
unit_ids = [u.id for u in units]
|
||||
active_deployments = db.query(DeploymentRecord.unit_id).filter(
|
||||
DeploymentRecord.unit_id.in_(unit_ids),
|
||||
DeploymentRecord.actual_removal_date == None
|
||||
).all()
|
||||
unit_in_field = {row.unit_id for row in active_deployments}
|
||||
|
||||
# Generate data for each of the 12 months
|
||||
months_data = []
|
||||
current_year = start_year
|
||||
@@ -640,28 +688,37 @@ def get_available_units_for_period(
|
||||
for a in assigned:
|
||||
reserved_unit_ids.add(a.unit_id)
|
||||
|
||||
# Get units with active deployment records (still in the field)
|
||||
unit_ids = [u.id for u in units]
|
||||
active_deps = db.query(DeploymentRecord.unit_id).filter(
|
||||
DeploymentRecord.unit_id.in_(unit_ids),
|
||||
DeploymentRecord.actual_removal_date == None
|
||||
).all()
|
||||
in_field_unit_ids = {row.unit_id for row in active_deps}
|
||||
|
||||
available_units = []
|
||||
for unit in units:
|
||||
# Check if already reserved
|
||||
if unit.id in reserved_unit_ids:
|
||||
continue
|
||||
# Check if currently in the field
|
||||
if unit.id in in_field_unit_ids:
|
||||
continue
|
||||
|
||||
# Check calibration through end of period
|
||||
if not unit.last_calibrated:
|
||||
continue # Needs calibration
|
||||
|
||||
expiry_date = unit.last_calibrated + timedelta(days=365)
|
||||
if expiry_date <= end_date:
|
||||
continue # Calibration expires during period
|
||||
|
||||
cal_status = get_calibration_status(unit, end_date, warning_days)
|
||||
if unit.last_calibrated:
|
||||
expiry_date = unit.last_calibrated + timedelta(days=365)
|
||||
cal_status = get_calibration_status(unit, end_date, warning_days)
|
||||
else:
|
||||
expiry_date = None
|
||||
cal_status = "needs_calibration"
|
||||
|
||||
available_units.append({
|
||||
"id": unit.id,
|
||||
"last_calibrated": unit.last_calibrated.isoformat(),
|
||||
"expiry_date": expiry_date.isoformat(),
|
||||
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
||||
"expiry_date": expiry_date.isoformat() if expiry_date else None,
|
||||
"calibration_status": cal_status,
|
||||
"deployed": unit.deployed,
|
||||
"out_for_calibration": unit.out_for_calibration or False,
|
||||
"note": unit.note or ""
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,435 @@
|
||||
"""
|
||||
project_merge.py — consolidate a duplicate project into another.
|
||||
|
||||
Use case: the metadata-backfill parser (and operators) create projects with
|
||||
slight name variations ("SR81" vs "SR 81", "Swank-Karns Crossing" vs
|
||||
"Swank-Karns Crossings", "Trumbull-Bryman Mont.Dam" vs
|
||||
"Trumbull-Brayman-Mont Dam"). Operator picks a SOURCE project to merge
|
||||
into a TARGET project; everything attached to source moves to target,
|
||||
same-named locations consolidate, and source is soft-deleted.
|
||||
|
||||
Public API:
|
||||
preview(db, source_id, target_id) → MergePreview
|
||||
execute(db, source_id, target_id, *, decided_by="operator") → MergeResult
|
||||
|
||||
Both raise HTTPException with appropriate 4xx codes for validation failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import (
|
||||
Project,
|
||||
ProjectModule,
|
||||
MonitoringLocation,
|
||||
UnitAssignment,
|
||||
UnitHistory,
|
||||
MonitoringSession,
|
||||
DataFile,
|
||||
)
|
||||
|
||||
log = logging.getLogger("backend.services.project_merge")
|
||||
|
||||
|
||||
# ── Dataclasses ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocationMergePlan:
|
||||
source_id: str
|
||||
source_name: str
|
||||
target_id: Optional[str] # None = will be inserted as-new under target project
|
||||
target_name: Optional[str] # name in target after merge
|
||||
action: str # "move" | "consolidate"
|
||||
assignments_moving: int
|
||||
sessions_moving: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergePreview:
|
||||
source_project_id: str
|
||||
source_project_name: str
|
||||
target_project_id: str
|
||||
target_project_name: str
|
||||
location_plans: list[LocationMergePlan] = field(default_factory=list)
|
||||
total_assignments_moving: int = 0
|
||||
total_sessions_moving: int = 0
|
||||
total_data_files_moving: int = 0
|
||||
modules_to_add: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergeResult:
|
||||
source_project_id: str
|
||||
target_project_id: str
|
||||
assignments_moved: int
|
||||
locations_moved: int
|
||||
locations_consolidated: int
|
||||
sessions_moved: int
|
||||
data_files_moved: int
|
||||
modules_added: list[str]
|
||||
audit_rows_written: int
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _normalise_name(s: Optional[str]) -> str:
|
||||
"""Case-insensitive, whitespace-collapsing name normalisation.
|
||||
|
||||
Lighter than metadata_backfill._normalise (no punctuation stripping)
|
||||
— for merging we want "Loc 1" and "Loc 1" to match but NOT "Loc 1"
|
||||
and "Loc-1" (those might be intentionally different). If operators
|
||||
DO want loose matching, they can rename one before merging.
|
||||
"""
|
||||
if not s:
|
||||
return ""
|
||||
import re
|
||||
return re.sub(r"\s+", " ", s.strip()).casefold()
|
||||
|
||||
|
||||
def _validate_pair(db: Session, source_id: str, target_id: str) -> tuple[Project, Project]:
|
||||
if source_id == target_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot merge a project into itself.")
|
||||
|
||||
source = db.query(Project).filter_by(id=source_id).first()
|
||||
target = db.query(Project).filter_by(id=target_id).first()
|
||||
if source is None:
|
||||
raise HTTPException(status_code=404, detail=f"Source project not found.")
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail=f"Target project not found.")
|
||||
if source.status == "deleted":
|
||||
raise HTTPException(status_code=400, detail=f"Source project '{source.name}' is already deleted.")
|
||||
if target.status == "deleted":
|
||||
raise HTTPException(status_code=400, detail=f"Target project '{target.name}' is deleted.")
|
||||
|
||||
return source, target
|
||||
|
||||
|
||||
# ── Preview ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def preview(db: Session, source_id: str, target_id: str) -> MergePreview:
|
||||
"""Build a preview of what the merge will do. No writes."""
|
||||
source, target = _validate_pair(db, source_id, target_id)
|
||||
|
||||
# Locations in source vs target.
|
||||
source_locs = (
|
||||
db.query(MonitoringLocation)
|
||||
.filter(MonitoringLocation.project_id == source_id)
|
||||
.all()
|
||||
)
|
||||
target_locs = (
|
||||
db.query(MonitoringLocation)
|
||||
.filter(MonitoringLocation.project_id == target_id)
|
||||
.all()
|
||||
)
|
||||
target_by_norm = {_normalise_name(l.name): l for l in target_locs}
|
||||
|
||||
location_plans: list[LocationMergePlan] = []
|
||||
total_assignments_moving = 0
|
||||
total_sessions_moving = 0
|
||||
|
||||
for sl in source_locs:
|
||||
n = _normalise_name(sl.name)
|
||||
tl = target_by_norm.get(n)
|
||||
|
||||
a_count = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.location_id == sl.id)
|
||||
.count()
|
||||
)
|
||||
s_count = (
|
||||
db.query(MonitoringSession)
|
||||
.filter(MonitoringSession.location_id == sl.id)
|
||||
.count()
|
||||
)
|
||||
total_assignments_moving += a_count
|
||||
total_sessions_moving += s_count
|
||||
|
||||
if tl is not None:
|
||||
location_plans.append(LocationMergePlan(
|
||||
source_id = sl.id,
|
||||
source_name = sl.name,
|
||||
target_id = tl.id,
|
||||
target_name = tl.name,
|
||||
action = "consolidate",
|
||||
assignments_moving = a_count,
|
||||
sessions_moving = s_count,
|
||||
))
|
||||
else:
|
||||
location_plans.append(LocationMergePlan(
|
||||
source_id = sl.id,
|
||||
source_name = sl.name,
|
||||
target_id = None,
|
||||
target_name = sl.name,
|
||||
action = "move",
|
||||
assignments_moving = a_count,
|
||||
sessions_moving = s_count,
|
||||
))
|
||||
|
||||
# DataFiles attached to the source project (if the table exists with a
|
||||
# project_id column). Optional — terra-view's DataFile model may not
|
||||
# always FK to project, so handle gracefully.
|
||||
df_count = 0
|
||||
try:
|
||||
df_count = (
|
||||
db.query(DataFile)
|
||||
.filter(DataFile.project_id == source_id)
|
||||
.count()
|
||||
)
|
||||
except Exception:
|
||||
df_count = 0
|
||||
total_data_files_moving = df_count
|
||||
|
||||
# Modules: add anything in source missing from target.
|
||||
src_modules = {
|
||||
m.module_type for m in db.query(ProjectModule)
|
||||
.filter(ProjectModule.project_id == source_id, ProjectModule.enabled.is_(True))
|
||||
.all()
|
||||
}
|
||||
tgt_modules = {
|
||||
m.module_type for m in db.query(ProjectModule)
|
||||
.filter(ProjectModule.project_id == target_id, ProjectModule.enabled.is_(True))
|
||||
.all()
|
||||
}
|
||||
modules_to_add = sorted(src_modules - tgt_modules)
|
||||
|
||||
warnings: list[str] = []
|
||||
# Surface conditions the operator should think about.
|
||||
consolidations = sum(1 for p in location_plans if p.action == "consolidate")
|
||||
if consolidations:
|
||||
warnings.append(
|
||||
f"{consolidations} location(s) with matching names will be consolidated "
|
||||
f"(source assignments will move to the target's existing location). "
|
||||
f"If your same-named locations are actually different sites, rename one first."
|
||||
)
|
||||
if source.client_name and target.client_name and source.client_name.strip().casefold() != target.client_name.strip().casefold():
|
||||
warnings.append(
|
||||
f"Client names differ: source is \"{source.client_name}\", target is "
|
||||
f"\"{target.client_name}\". Target's client name will be kept."
|
||||
)
|
||||
|
||||
return MergePreview(
|
||||
source_project_id = source.id,
|
||||
source_project_name = source.name,
|
||||
target_project_id = target.id,
|
||||
target_project_name = target.name,
|
||||
location_plans = location_plans,
|
||||
total_assignments_moving = total_assignments_moving,
|
||||
total_sessions_moving = total_sessions_moving,
|
||||
total_data_files_moving = total_data_files_moving,
|
||||
modules_to_add = modules_to_add,
|
||||
warnings = warnings,
|
||||
)
|
||||
|
||||
|
||||
# ── Execute ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def execute(
|
||||
db: Session,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
*,
|
||||
decided_by: str = "operator",
|
||||
) -> MergeResult:
|
||||
"""Perform the merge in a single transaction.
|
||||
|
||||
Steps:
|
||||
1. Re-validate the pair.
|
||||
2. For each location in source:
|
||||
- if a same-name location exists in target → "consolidate" mode:
|
||||
move source's assignments + sessions to target's location id,
|
||||
delete source's location.
|
||||
- else → "move" mode: just re-point the location's project_id.
|
||||
3. Move any remaining direct-to-project FK rows (DataFiles).
|
||||
4. Ensure target has all of source's modules.
|
||||
5. Soft-delete source project.
|
||||
6. Write a UnitHistory row per assignment that was moved
|
||||
(change_type='assignment_merged') so the deployment timeline
|
||||
on each affected unit reflects the merge.
|
||||
7. Commit.
|
||||
"""
|
||||
source, target = _validate_pair(db, source_id, target_id)
|
||||
|
||||
src_modules = {
|
||||
m.module_type for m in db.query(ProjectModule)
|
||||
.filter(ProjectModule.project_id == source_id, ProjectModule.enabled.is_(True))
|
||||
.all()
|
||||
}
|
||||
tgt_modules = {
|
||||
m.module_type for m in db.query(ProjectModule)
|
||||
.filter(ProjectModule.project_id == target_id, ProjectModule.enabled.is_(True))
|
||||
.all()
|
||||
}
|
||||
modules_to_add = sorted(src_modules - tgt_modules)
|
||||
|
||||
# ── 1. Locations + their dependents ───────────────────────────────
|
||||
source_locs = (
|
||||
db.query(MonitoringLocation)
|
||||
.filter(MonitoringLocation.project_id == source_id)
|
||||
.all()
|
||||
)
|
||||
target_locs = (
|
||||
db.query(MonitoringLocation)
|
||||
.filter(MonitoringLocation.project_id == target_id)
|
||||
.all()
|
||||
)
|
||||
target_by_norm = {_normalise_name(l.name): l for l in target_locs}
|
||||
|
||||
assignments_moved = 0
|
||||
sessions_moved = 0
|
||||
locations_moved = 0
|
||||
locations_consolidated = 0
|
||||
audit_rows_written = 0
|
||||
|
||||
for sl in source_locs:
|
||||
n = _normalise_name(sl.name)
|
||||
tl = target_by_norm.get(n)
|
||||
|
||||
# Pull this location's assignments + sessions (we'll re-point them).
|
||||
assignments = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.location_id == sl.id)
|
||||
.all()
|
||||
)
|
||||
sessions = (
|
||||
db.query(MonitoringSession)
|
||||
.filter(MonitoringSession.location_id == sl.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
if tl is not None:
|
||||
# Consolidate: move dependents to target's existing location;
|
||||
# then delete the source location.
|
||||
for a in assignments:
|
||||
old_loc_id = a.location_id
|
||||
a.location_id = tl.id
|
||||
a.project_id = target.id
|
||||
|
||||
db.add(UnitHistory(
|
||||
unit_id = a.unit_id,
|
||||
change_type = "assignment_merged",
|
||||
field_name = "unit_assignment.project_id",
|
||||
old_value = f"{source.name} / {sl.name}",
|
||||
new_value = f"{target.name} / {tl.name}",
|
||||
changed_at = datetime.utcnow(),
|
||||
source = "project_merge",
|
||||
notes = (
|
||||
f"Project merge: '{source.name}' → '{target.name}'. "
|
||||
f"Location consolidated by name match. "
|
||||
f"By: {decided_by}."
|
||||
),
|
||||
))
|
||||
audit_rows_written += 1
|
||||
assignments_moved += 1
|
||||
|
||||
for s in sessions:
|
||||
s.location_id = tl.id
|
||||
s.project_id = target.id
|
||||
sessions_moved += 1
|
||||
|
||||
# Delete the now-empty source location.
|
||||
db.delete(sl)
|
||||
locations_consolidated += 1
|
||||
else:
|
||||
# Move: just re-point this location to the target project.
|
||||
sl.project_id = target.id
|
||||
|
||||
for a in assignments:
|
||||
old_proj_id = a.project_id
|
||||
a.project_id = target.id
|
||||
|
||||
db.add(UnitHistory(
|
||||
unit_id = a.unit_id,
|
||||
change_type = "assignment_merged",
|
||||
field_name = "unit_assignment.project_id",
|
||||
old_value = f"{source.name} / {sl.name}",
|
||||
new_value = f"{target.name} / {sl.name}",
|
||||
changed_at = datetime.utcnow(),
|
||||
source = "project_merge",
|
||||
notes = (
|
||||
f"Project merge: '{source.name}' → '{target.name}'. "
|
||||
f"Location moved as-is. By: {decided_by}."
|
||||
),
|
||||
))
|
||||
audit_rows_written += 1
|
||||
assignments_moved += 1
|
||||
|
||||
for s in sessions:
|
||||
s.project_id = target.id
|
||||
sessions_moved += 1
|
||||
|
||||
locations_moved += 1
|
||||
|
||||
# ── 2. Direct-to-project rows (DataFiles, ScheduledActions) ──────
|
||||
data_files_moved = 0
|
||||
try:
|
||||
data_files = (
|
||||
db.query(DataFile)
|
||||
.filter(DataFile.project_id == source_id)
|
||||
.all()
|
||||
)
|
||||
for df in data_files:
|
||||
df.project_id = target.id
|
||||
data_files_moved += 1
|
||||
except Exception as e:
|
||||
log.warning("DataFile move skipped (model may differ): %s", e)
|
||||
|
||||
# ── 3. UnitAssignments that point directly at source.project_id with
|
||||
# no location (shouldn't happen but be defensive) ──────────────
|
||||
orphan_assignments = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.project_id == source_id)
|
||||
.all()
|
||||
)
|
||||
for a in orphan_assignments:
|
||||
# Already moved if its location was moved. Catch any stragglers.
|
||||
if a.project_id == source_id:
|
||||
a.project_id = target.id
|
||||
|
||||
# ── 4. Modules ────────────────────────────────────────────────────
|
||||
import uuid
|
||||
for mod_type in modules_to_add:
|
||||
db.add(ProjectModule(
|
||||
id = str(uuid.uuid4()),
|
||||
project_id = target.id,
|
||||
module_type = mod_type,
|
||||
enabled = True,
|
||||
))
|
||||
|
||||
# Disable source's modules (defensive — source is being soft-deleted
|
||||
# but its modules table rows could still be inspected).
|
||||
for m in db.query(ProjectModule).filter(ProjectModule.project_id == source_id).all():
|
||||
m.enabled = False
|
||||
|
||||
# ── 5. Soft-delete source ─────────────────────────────────────────
|
||||
source.status = "deleted"
|
||||
source.deleted_at = datetime.utcnow()
|
||||
|
||||
# Final audit row on the source project itself (operator-facing).
|
||||
# We don't have a Project-level history table, so log on every
|
||||
# affected unit as a marker. Already done per-assignment above.
|
||||
|
||||
db.commit()
|
||||
|
||||
return MergeResult(
|
||||
source_project_id = source.id,
|
||||
target_project_id = target.id,
|
||||
assignments_moved = assignments_moved,
|
||||
locations_moved = locations_moved,
|
||||
locations_consolidated = locations_consolidated,
|
||||
sessions_moved = sessions_moved,
|
||||
data_files_moved = data_files_moved,
|
||||
modules_added = modules_to_add,
|
||||
audit_rows_written = audit_rows_written,
|
||||
)
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
project_tidy.py — find duplicate-looking projects + offer bulk merge.
|
||||
|
||||
The metadata-backfill parser is good at clustering events into candidate
|
||||
projects but doesn't compare its proposed project names against EACH OTHER
|
||||
(it only checks against existing terra-view projects). After a bulk
|
||||
apply, you can end up with many near-duplicate projects — typo variants,
|
||||
abbreviation differences, etc. This module surfaces them as pairs the
|
||||
operator can merge.
|
||||
|
||||
Pairs vs clusters: a fully-connected group like (A, B, C) where each pair
|
||||
scores >= threshold becomes 3 pairs. The operator has to do 2 merges to
|
||||
fully consolidate. We don't try to be smarter about transitive grouping —
|
||||
in practice operators want to review the highest-similarity pair first
|
||||
anyway, and the list re-computes after each merge.
|
||||
|
||||
Public API:
|
||||
find_duplicate_pairs(db, *, threshold=0.85, max_pairs=200) → list[DuplicatePair]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import rapidfuzz
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import (
|
||||
Project,
|
||||
MonitoringLocation,
|
||||
UnitAssignment,
|
||||
)
|
||||
from backend.services.metadata_backfill import _normalise as _meta_normalise
|
||||
|
||||
log = logging.getLogger("backend.services.project_tidy")
|
||||
|
||||
|
||||
DEFAULT_THRESHOLD = 0.85 # WRatio similarity above which we surface a pair
|
||||
DEFAULT_MAX_PAIRS = 200 # Cap the result list to keep response small
|
||||
MIN_NORMALISED_LENGTH = 4 # Skip projects whose normalised name is too short
|
||||
# to fuzzy-match safely (avoids "1" / "1" pairs).
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectSummary:
|
||||
id: str
|
||||
name: str
|
||||
project_number: Optional[str]
|
||||
client_name: Optional[str]
|
||||
source: str # 'manual' | 'metadata_backfill' | ...
|
||||
status: str
|
||||
location_count: int
|
||||
assignment_count: int
|
||||
event_count_total: int # approx — sum across assignments
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuplicatePair:
|
||||
a: ProjectSummary
|
||||
b: ProjectSummary
|
||||
score: float
|
||||
suggested_target_id: str # the recommended "keep" side
|
||||
reason: str # why we picked that target
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _normalise_project_name(name: str) -> str:
|
||||
"""Project-name normalisation for tidy comparison.
|
||||
|
||||
Reuses the metadata_backfill normaliser (lowercase, punctuation→space,
|
||||
collapse whitespace). Returns "" for None or all-punctuation names.
|
||||
"""
|
||||
return _meta_normalise(name)
|
||||
|
||||
|
||||
def _summarise_projects(db: Session) -> list[ProjectSummary]:
|
||||
"""One row per active project with cached counts. Excludes deleted."""
|
||||
projects = (
|
||||
db.query(Project)
|
||||
.filter(Project.status != "deleted")
|
||||
.all()
|
||||
)
|
||||
|
||||
# Bulk lookup: assignment counts + location counts per project.
|
||||
loc_counts: dict[str, int] = dict(
|
||||
db.query(MonitoringLocation.project_id, func.count(MonitoringLocation.id))
|
||||
.filter(MonitoringLocation.project_id.in_([p.id for p in projects]) if projects else False)
|
||||
.group_by(MonitoringLocation.project_id)
|
||||
.all()
|
||||
)
|
||||
asgn_counts: dict[str, int] = dict(
|
||||
db.query(UnitAssignment.project_id, func.count(UnitAssignment.id))
|
||||
.filter(UnitAssignment.project_id.in_([p.id for p in projects]) if projects else False)
|
||||
.group_by(UnitAssignment.project_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
summaries: list[ProjectSummary] = []
|
||||
for p in projects:
|
||||
summaries.append(ProjectSummary(
|
||||
id = p.id,
|
||||
name = p.name,
|
||||
project_number = p.project_number,
|
||||
client_name = p.client_name,
|
||||
source = None, # filled below per assignment
|
||||
status = p.status or "active",
|
||||
location_count = loc_counts.get(p.id, 0),
|
||||
assignment_count = asgn_counts.get(p.id, 0),
|
||||
event_count_total = 0, # not cheap to compute here; left 0
|
||||
))
|
||||
|
||||
# Determine each project's dominant assignment source. Used to break ties
|
||||
# when picking the "keep" target — prefer manual over parser-created.
|
||||
rows = (
|
||||
db.query(UnitAssignment.project_id, UnitAssignment.source, func.count(UnitAssignment.id))
|
||||
.group_by(UnitAssignment.project_id, UnitAssignment.source)
|
||||
.all()
|
||||
)
|
||||
by_proj_src: dict[str, dict[str, int]] = {}
|
||||
for proj_id, src, cnt in rows:
|
||||
by_proj_src.setdefault(proj_id, {})[src or "manual"] = cnt
|
||||
for s in summaries:
|
||||
src_map = by_proj_src.get(s.id, {})
|
||||
if not src_map:
|
||||
s.source = "manual"
|
||||
else:
|
||||
# Dominant source (most assignments).
|
||||
s.source = max(src_map.items(), key=lambda kv: kv[1])[0]
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
def _pick_target(a: ProjectSummary, b: ProjectSummary) -> tuple[str, str]:
|
||||
"""Decide which project should be the merge target (the one we keep).
|
||||
|
||||
Priorities (in order):
|
||||
1. The one with `source='manual'` over `source='metadata_backfill'`
|
||||
— operator-curated projects beat parser-created ones.
|
||||
2. The one with a populated `project_number`.
|
||||
3. The one with more locations (more curation history).
|
||||
4. The one with more assignments.
|
||||
5. The one with the shorter, cleaner name (tiebreaker).
|
||||
|
||||
Returns (target_id, reason_string).
|
||||
"""
|
||||
# 1. Source provenance.
|
||||
a_manual = a.source == "manual"
|
||||
b_manual = b.source == "manual"
|
||||
if a_manual and not b_manual:
|
||||
return a.id, "A is manually-created; B is parser-created"
|
||||
if b_manual and not a_manual:
|
||||
return b.id, "B is manually-created; A is parser-created"
|
||||
|
||||
# 2. project_number populated.
|
||||
if a.project_number and not b.project_number:
|
||||
return a.id, "A has a project_number; B doesn't"
|
||||
if b.project_number and not a.project_number:
|
||||
return b.id, "B has a project_number; A doesn't"
|
||||
|
||||
# 3. More locations.
|
||||
if a.location_count > b.location_count:
|
||||
return a.id, f"A has more locations ({a.location_count} vs {b.location_count})"
|
||||
if b.location_count > a.location_count:
|
||||
return b.id, f"B has more locations ({b.location_count} vs {a.location_count})"
|
||||
|
||||
# 4. More assignments.
|
||||
if a.assignment_count > b.assignment_count:
|
||||
return a.id, f"A has more assignments ({a.assignment_count} vs {b.assignment_count})"
|
||||
if b.assignment_count > a.assignment_count:
|
||||
return b.id, f"B has more assignments ({b.assignment_count} vs {a.assignment_count})"
|
||||
|
||||
# 5. Shorter name (less likely to have baked-in junk).
|
||||
if len(a.name) <= len(b.name):
|
||||
return a.id, "A has the shorter / cleaner name"
|
||||
return b.id, "B has the shorter / cleaner name"
|
||||
|
||||
|
||||
# ── Public ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def find_duplicate_pairs(
|
||||
db: Session,
|
||||
*,
|
||||
threshold: float = DEFAULT_THRESHOLD,
|
||||
max_pairs: int = DEFAULT_MAX_PAIRS,
|
||||
) -> list[DuplicatePair]:
|
||||
"""Compute all project-pair similarities above `threshold`.
|
||||
|
||||
O(N^2) over the project count — fine up to ~500 projects; beyond that
|
||||
we'd want a blocked / token-indexed approach. In practice
|
||||
`metadata_backfill` projects tend to share tokens, so a simple
|
||||
pre-filter (skip pairs that share NO tokens) would cheaply cut the
|
||||
inner loop. Deferred until profiling motivates it.
|
||||
"""
|
||||
summaries = _summarise_projects(db)
|
||||
|
||||
# Pre-compute normalised names; skip too-short ones.
|
||||
norm_by_id: dict[str, str] = {}
|
||||
candidates: list[ProjectSummary] = []
|
||||
for s in summaries:
|
||||
n = _normalise_project_name(s.name)
|
||||
if len(n) < MIN_NORMALISED_LENGTH:
|
||||
continue
|
||||
norm_by_id[s.id] = n
|
||||
candidates.append(s)
|
||||
|
||||
pairs: list[DuplicatePair] = []
|
||||
n = len(candidates)
|
||||
for i in range(n):
|
||||
a = candidates[i]
|
||||
a_norm = norm_by_id[a.id]
|
||||
for j in range(i + 1, n):
|
||||
b = candidates[j]
|
||||
b_norm = norm_by_id[b.id]
|
||||
score = rapidfuzz.fuzz.WRatio(a_norm, b_norm) / 100.0
|
||||
if score < threshold:
|
||||
continue
|
||||
target_id, reason = _pick_target(a, b)
|
||||
pairs.append(DuplicatePair(
|
||||
a = a,
|
||||
b = b,
|
||||
score = score,
|
||||
suggested_target_id = target_id,
|
||||
reason = reason,
|
||||
))
|
||||
|
||||
# Sort by score desc, then by total content (more data → review first).
|
||||
pairs.sort(key=lambda p: (-p.score, -(p.a.assignment_count + p.b.assignment_count)))
|
||||
|
||||
return pairs[:max_pairs]
|
||||
@@ -0,0 +1,592 @@
|
||||
"""
|
||||
SFM events service — bridge between terra-view's UnitAssignment time-windows
|
||||
and the SFM (seismo-relay) events store.
|
||||
|
||||
Architecture:
|
||||
1. Terra-view owns the *assignment graph*: which seismograph was at which
|
||||
monitoring location during which time window (UnitAssignment rows).
|
||||
2. SFM owns the *events store*: triggered waveform events keyed by
|
||||
(serial, timestamp), forwarded from Blastware ACH by series3-watcher.
|
||||
3. This module fans out the assignments for a given location, queries SFM
|
||||
for the events emitted by each (serial, window) pair concurrently, and
|
||||
unions/sorts/paginates the results.
|
||||
|
||||
SFM remains the single source of truth for events. Terra-view does not
|
||||
copy events into its own DB; every query hits SFM live.
|
||||
|
||||
The events_for_location helper is also reused by Phase 3 (project-level
|
||||
roll-up) to aggregate across every location in a project.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import UnitAssignment, RosterUnit, MonitoringLocation, Project
|
||||
|
||||
log = logging.getLogger("backend.services.sfm_events")
|
||||
|
||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||
|
||||
# Per-request timeout when calling SFM /db/events. SFM is local on the
|
||||
# docker network so this should be fast; bump if you start seeing timeouts.
|
||||
_SFM_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
# Max events we ever fetch per (serial, window) call to SFM. Must match
|
||||
# SFM's own /db/events max limit (currently 5000). The user-facing display
|
||||
# limit is independent — we over-fetch up to this cap so summary stats are
|
||||
# accurate, then trim the displayed list to the requested limit.
|
||||
_SFM_FETCH_CEILING = 5000
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _iso_utc(dt: Optional[datetime]) -> Optional[str]:
|
||||
"""Render a datetime in the ISO format SFM /db/events expects."""
|
||||
if dt is None:
|
||||
return None
|
||||
# SFM parses naive ISO strings as UTC; strip tzinfo for consistency.
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt.isoformat(sep=" ", timespec="seconds")
|
||||
|
||||
|
||||
def _intersect_window(
|
||||
assignment_start: datetime,
|
||||
assignment_end: Optional[datetime],
|
||||
filter_from: Optional[datetime],
|
||||
filter_to: Optional[datetime],
|
||||
now: datetime,
|
||||
) -> Optional[tuple[datetime, datetime]]:
|
||||
"""Intersect an assignment window with the requested filter window.
|
||||
|
||||
Returns (effective_start, effective_end) or None if there's no overlap.
|
||||
Open-ended assignments (assigned_until=NULL) are bounded by `now`.
|
||||
"""
|
||||
a_end = assignment_end or now
|
||||
if filter_from and a_end <= filter_from:
|
||||
return None
|
||||
if filter_to and assignment_start >= filter_to:
|
||||
return None
|
||||
start = max(assignment_start, filter_from) if filter_from else assignment_start
|
||||
end = min(a_end, filter_to) if filter_to else a_end
|
||||
if end <= start:
|
||||
return None
|
||||
return (start, end)
|
||||
|
||||
|
||||
async def _fetch_events_for_serial(
|
||||
client: httpx.AsyncClient,
|
||||
serial: str,
|
||||
*,
|
||||
from_dt: datetime,
|
||||
to_dt: datetime,
|
||||
false_trigger: Optional[bool],
|
||||
limit: int,
|
||||
) -> list[dict]:
|
||||
"""Issue one /db/events call to SFM for one (serial, window) pair."""
|
||||
params: dict[str, str] = {
|
||||
"serial": serial,
|
||||
"from_dt": _iso_utc(from_dt) or "",
|
||||
"to_dt": _iso_utc(to_dt) or "",
|
||||
"limit": str(limit),
|
||||
}
|
||||
if false_trigger is not None:
|
||||
params["false_trigger"] = "true" if false_trigger else "false"
|
||||
|
||||
try:
|
||||
resp = await client.get(f"{SFM_BASE_URL}/db/events", params=params)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as e:
|
||||
log.warning("SFM /db/events failed for serial=%s: %s", serial, e)
|
||||
return []
|
||||
|
||||
payload = resp.json()
|
||||
events = payload.get("events", []) or []
|
||||
# Strip waveform_blob if present — it's the big per-event binary and we
|
||||
# don't render it in the list view. SFM returns it by default.
|
||||
for ev in events:
|
||||
ev.pop("waveform_blob", None)
|
||||
ev.pop("a5_pickle_filename", None)
|
||||
return events
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def events_for_location(
|
||||
db: Session,
|
||||
location_id: str,
|
||||
*,
|
||||
from_dt: Optional[datetime] = None,
|
||||
to_dt: Optional[datetime] = None,
|
||||
false_trigger: Optional[bool] = None,
|
||||
limit: int = 500,
|
||||
) -> dict:
|
||||
"""Fan out UnitAssignment rows for `location_id` and union SFM events.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"events": [merged event dicts, newest first, capped at limit],
|
||||
"count": total events found across all windows (pre-cap),
|
||||
"stats": {event_count, peak_pvs, peak_pvs_at,
|
||||
last_event, false_trigger_count},
|
||||
"assignments_used": [{unit_id, assigned_at, assigned_until,
|
||||
events_in_window}, ...],
|
||||
}
|
||||
|
||||
The "events outside any assignment window" rule (Phase 1 design decision):
|
||||
events whose timestamp falls outside every assignment window are simply
|
||||
not fetched — we only ask SFM for events inside the intersected windows.
|
||||
Those orphan events surface under the per-unit detail page in Phase 2.
|
||||
"""
|
||||
# 1. Fetch all assignments (active + closed) for the location.
|
||||
assignments = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.location_id == location_id)
|
||||
.filter(UnitAssignment.device_type == "seismograph")
|
||||
.order_by(UnitAssignment.assigned_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
if not assignments:
|
||||
return {
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"stats": _empty_stats(),
|
||||
"assignments_used": [],
|
||||
}
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# 2. For each assignment, compute the effective (start, end) window after
|
||||
# intersecting with the requested filter range. Drop assignments that
|
||||
# don't overlap the filter window.
|
||||
fetch_specs: list[tuple[UnitAssignment, datetime, datetime]] = []
|
||||
for a in assignments:
|
||||
window = _intersect_window(a.assigned_at, a.assigned_until, from_dt, to_dt, now)
|
||||
if window is not None:
|
||||
fetch_specs.append((a, window[0], window[1]))
|
||||
|
||||
if not fetch_specs:
|
||||
return {
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"stats": _empty_stats(),
|
||||
"assignments_used": [
|
||||
{
|
||||
"unit_id": a.unit_id,
|
||||
"assigned_at": _iso_utc(a.assigned_at),
|
||||
"assigned_until": _iso_utc(a.assigned_until),
|
||||
"events_in_window": 0,
|
||||
}
|
||||
for a in assignments
|
||||
],
|
||||
}
|
||||
|
||||
# 3. Concurrent SFM fetches. We over-fetch (up to _SFM_FETCH_CEILING per
|
||||
# window) so summary stats reflect the true peak/last/count across the
|
||||
# full filter window, not just what fits in the user's display limit.
|
||||
# The displayed event list is trimmed to `limit` after merge.
|
||||
async with httpx.AsyncClient(timeout=_SFM_TIMEOUT_SECONDS) as client:
|
||||
per_window_lists = await asyncio.gather(
|
||||
*(
|
||||
_fetch_events_for_serial(
|
||||
client,
|
||||
serial=a.unit_id,
|
||||
from_dt=start,
|
||||
to_dt=end,
|
||||
false_trigger=false_trigger,
|
||||
limit=_SFM_FETCH_CEILING,
|
||||
)
|
||||
for a, start, end in fetch_specs
|
||||
),
|
||||
return_exceptions=False,
|
||||
)
|
||||
|
||||
# 4. Build the per-assignment event counts (transparency for the operator).
|
||||
spec_event_counts: dict[str, int] = {}
|
||||
for (a, _start, _end), evs in zip(fetch_specs, per_window_lists):
|
||||
spec_event_counts[a.id] = len(evs)
|
||||
|
||||
# 5. Union, sort newest-first, cap.
|
||||
merged: list[dict] = []
|
||||
for evs in per_window_lists:
|
||||
merged.extend(evs)
|
||||
merged.sort(key=lambda e: e.get("timestamp") or "", reverse=True)
|
||||
total_count = len(merged)
|
||||
capped = merged[:limit]
|
||||
|
||||
# 6. Compute summary stats over the full merged set (not the capped one).
|
||||
stats = _compute_stats(merged)
|
||||
|
||||
# 7. Build the assignments_used report (every assignment, in chronological
|
||||
# order, with its event count — even ones that fell outside the filter
|
||||
# window so the operator sees them but with count=0).
|
||||
assignments_used = []
|
||||
for a in assignments:
|
||||
assignments_used.append(
|
||||
{
|
||||
"unit_id": a.unit_id,
|
||||
"assignment_id": a.id,
|
||||
"assigned_at": _iso_utc(a.assigned_at),
|
||||
"assigned_until": _iso_utc(a.assigned_until),
|
||||
"events_in_window": spec_event_counts.get(a.id, 0),
|
||||
"status": a.status,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"events": capped,
|
||||
"count": total_count,
|
||||
"stats": stats,
|
||||
"assignments_used": assignments_used,
|
||||
}
|
||||
|
||||
|
||||
# ── Per-unit (cross-project) view ─────────────────────────────────────────────
|
||||
|
||||
|
||||
async def events_for_unit(
|
||||
db: Session,
|
||||
unit_id: str,
|
||||
*,
|
||||
bucket: str = "all", # "all" | "attributed" | "unattributed"
|
||||
from_dt: Optional[datetime] = None,
|
||||
to_dt: Optional[datetime] = None,
|
||||
false_trigger: Optional[bool] = None,
|
||||
limit: int = 500,
|
||||
) -> dict:
|
||||
"""Return events for a unit annotated with their assignment attribution.
|
||||
|
||||
Unlike events_for_location (which queries SFM per assignment window), this
|
||||
helper queries SFM for ALL events for the serial within the optional
|
||||
[from_dt, to_dt] filter, then walks each event against the unit's
|
||||
UnitAssignment intervals to compute attribution.
|
||||
|
||||
Bucket semantics:
|
||||
- "all": every event, attributed or not
|
||||
- "attributed": events that fall inside at least one assignment window
|
||||
- "unattributed": events with no overlapping assignment (the diagnostic
|
||||
bucket — operator should fix assignment dates to
|
||||
attribute these)
|
||||
|
||||
Each event gets an extra `attribution` field:
|
||||
{assignment_id, location_id, location_name, project_id, project_name,
|
||||
assigned_at, assigned_until} or None
|
||||
|
||||
Unattributed events also get a `nearest_assignment` field with the
|
||||
same shape plus `delta_days` (signed; negative = event before assignment).
|
||||
"""
|
||||
# 1. Pull all assignments for this unit (any device_type — caller has
|
||||
# already filtered by seismograph in the route). Order matters: we
|
||||
# want the earliest-start assignment first so attribution prefers the
|
||||
# chronologically-first overlap when there are simultaneous active
|
||||
# assignments at different locations (rare but possible).
|
||||
assignments = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.unit_id == unit_id)
|
||||
.order_by(UnitAssignment.assigned_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Resolve location + project names once.
|
||||
loc_ids = {a.location_id for a in assignments}
|
||||
proj_ids = {a.project_id for a in assignments}
|
||||
loc_map = {
|
||||
l.id: l for l in db.query(MonitoringLocation).filter(
|
||||
MonitoringLocation.id.in_(loc_ids)
|
||||
).all()
|
||||
} if loc_ids else {}
|
||||
proj_map = {
|
||||
p.id: p for p in db.query(Project).filter(
|
||||
Project.id.in_(proj_ids)
|
||||
).all()
|
||||
} if proj_ids else {}
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
def _attr_dict(a: UnitAssignment) -> dict:
|
||||
loc = loc_map.get(a.location_id)
|
||||
proj = proj_map.get(a.project_id)
|
||||
return {
|
||||
"assignment_id": a.id,
|
||||
"location_id": a.location_id,
|
||||
"location_name": loc.name if loc else None,
|
||||
"project_id": a.project_id,
|
||||
"project_name": proj.name if proj else None,
|
||||
"assigned_at": _iso_utc(a.assigned_at),
|
||||
"assigned_until": _iso_utc(a.assigned_until),
|
||||
}
|
||||
|
||||
# 2. Fetch all events for this serial in one shot.
|
||||
async with httpx.AsyncClient(timeout=_SFM_TIMEOUT_SECONDS) as client:
|
||||
events = await _fetch_events_for_serial(
|
||||
client,
|
||||
serial=unit_id,
|
||||
from_dt=from_dt or datetime(1970, 1, 1),
|
||||
to_dt=to_dt or now,
|
||||
false_trigger=false_trigger,
|
||||
limit=_SFM_FETCH_CEILING,
|
||||
)
|
||||
|
||||
# 3. For each event, walk the assignment list and find the first
|
||||
# overlapping window. O(N * M) but both are small in practice.
|
||||
for ev in events:
|
||||
ts_str = ev.get("timestamp")
|
||||
if not ts_str:
|
||||
ev["attribution"] = None
|
||||
continue
|
||||
try:
|
||||
# SFM returns ISO with "T" separator; tolerate both.
|
||||
ts = datetime.fromisoformat(ts_str.replace(" ", "T"))
|
||||
except ValueError:
|
||||
ev["attribution"] = None
|
||||
continue
|
||||
|
||||
matched: Optional[UnitAssignment] = None
|
||||
for a in assignments:
|
||||
a_end = a.assigned_until or now
|
||||
if a.assigned_at <= ts <= a_end:
|
||||
matched = a
|
||||
break
|
||||
|
||||
if matched is not None:
|
||||
ev["attribution"] = _attr_dict(matched)
|
||||
else:
|
||||
ev["attribution"] = None
|
||||
# Find the nearest assignment (chronologically) for diagnostic.
|
||||
if assignments:
|
||||
nearest = min(
|
||||
assignments,
|
||||
key=lambda a: min(
|
||||
abs((ts - a.assigned_at).total_seconds()),
|
||||
abs((ts - (a.assigned_until or now)).total_seconds()),
|
||||
),
|
||||
)
|
||||
# Signed delta in days from the nearest boundary
|
||||
# (negative = event BEFORE that boundary).
|
||||
if ts < nearest.assigned_at:
|
||||
delta_seconds = (ts - nearest.assigned_at).total_seconds()
|
||||
elif ts > (nearest.assigned_until or now):
|
||||
delta_seconds = (ts - (nearest.assigned_until or now)).total_seconds()
|
||||
else:
|
||||
delta_seconds = 0
|
||||
ev["nearest_assignment"] = {
|
||||
**_attr_dict(nearest),
|
||||
"delta_days": round(delta_seconds / 86400, 1),
|
||||
}
|
||||
|
||||
# 4. Apply bucket filter.
|
||||
if bucket == "attributed":
|
||||
filtered = [e for e in events if e.get("attribution") is not None]
|
||||
elif bucket == "unattributed":
|
||||
filtered = [e for e in events if e.get("attribution") is None]
|
||||
else:
|
||||
filtered = events
|
||||
|
||||
filtered.sort(key=lambda e: e.get("timestamp") or "", reverse=True)
|
||||
total_count = len(filtered)
|
||||
capped = filtered[:limit]
|
||||
|
||||
# 5. Stats: compute over the ENTIRE event set (not the filtered bucket)
|
||||
# so the unattributed_count tile is always meaningful regardless of
|
||||
# which bucket the operator has selected.
|
||||
base_stats = _compute_stats(events)
|
||||
unattributed_count = sum(
|
||||
1 for e in events if e.get("attribution") is None
|
||||
)
|
||||
base_stats["unattributed_count"] = unattributed_count
|
||||
|
||||
return {
|
||||
"events": capped,
|
||||
"count": total_count,
|
||||
"stats": base_stats,
|
||||
"assignments_total": len(assignments),
|
||||
}
|
||||
|
||||
|
||||
# ── Project-level roll-up (aggregates across all vibration locations) ─────────
|
||||
|
||||
|
||||
async def vibration_summary_for_project(
|
||||
db: Session,
|
||||
project_id: str,
|
||||
*,
|
||||
from_dt: Optional[datetime] = None,
|
||||
to_dt: Optional[datetime] = None,
|
||||
) -> dict:
|
||||
"""Aggregate SFM events across every vibration location in a project.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"project_id": str,
|
||||
"total_events": int,
|
||||
"peak_pvs": float | None,
|
||||
"peak_pvs_at": ISO timestamp | None,
|
||||
"peak_pvs_location_id": str | None,
|
||||
"peak_pvs_location_name": str | None,
|
||||
"last_event": ISO timestamp | None,
|
||||
"false_trigger_count": int,
|
||||
"per_location": [
|
||||
{"location_id", "location_name", "event_count",
|
||||
"peak_pvs", "last_event"},
|
||||
... # sorted by event_count DESC
|
||||
],
|
||||
"vibration_location_count": int,
|
||||
}
|
||||
"""
|
||||
locations = (
|
||||
db.query(MonitoringLocation)
|
||||
.filter(MonitoringLocation.project_id == project_id)
|
||||
.filter(MonitoringLocation.location_type == "vibration")
|
||||
.all()
|
||||
)
|
||||
|
||||
if not locations:
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"total_events": 0,
|
||||
"peak_pvs": None,
|
||||
"peak_pvs_at": None,
|
||||
"peak_pvs_location_id": None,
|
||||
"peak_pvs_location_name": None,
|
||||
"last_event": None,
|
||||
"false_trigger_count": 0,
|
||||
"per_location": [],
|
||||
"vibration_location_count": 0,
|
||||
}
|
||||
|
||||
# Fan out across locations. Each call internally fans out across that
|
||||
# location's UnitAssignment rows, so this is a nested fan-out. Both
|
||||
# tiers happen concurrently because asyncio.gather + httpx pool.
|
||||
results = await asyncio.gather(
|
||||
*(
|
||||
events_for_location(
|
||||
db,
|
||||
loc.id,
|
||||
from_dt=from_dt,
|
||||
to_dt=to_dt,
|
||||
false_trigger=None,
|
||||
limit=1, # We only need stats; events list itself is ignored.
|
||||
)
|
||||
for loc in locations
|
||||
),
|
||||
return_exceptions=False,
|
||||
)
|
||||
|
||||
per_location: list[dict] = []
|
||||
total_events = 0
|
||||
peak_pvs = None
|
||||
peak_pvs_at = None
|
||||
peak_pvs_location_id = None
|
||||
peak_pvs_location_name = None
|
||||
last_event = None
|
||||
false_trigger_count = 0
|
||||
|
||||
for loc, res in zip(locations, results):
|
||||
st = res.get("stats", {}) or {}
|
||||
ec = st.get("event_count", 0) or 0
|
||||
total_events += ec
|
||||
false_trigger_count += st.get("false_trigger_count", 0) or 0
|
||||
|
||||
ev_last = st.get("last_event")
|
||||
if ev_last and (last_event is None or ev_last > last_event):
|
||||
last_event = ev_last
|
||||
|
||||
ev_peak = st.get("peak_pvs")
|
||||
if ev_peak is not None and (peak_pvs is None or ev_peak > peak_pvs):
|
||||
peak_pvs = ev_peak
|
||||
peak_pvs_at = st.get("peak_pvs_at")
|
||||
peak_pvs_location_id = loc.id
|
||||
peak_pvs_location_name = loc.name
|
||||
|
||||
per_location.append({
|
||||
"location_id": loc.id,
|
||||
"location_name": loc.name,
|
||||
"event_count": ec,
|
||||
"peak_pvs": ev_peak,
|
||||
"last_event": ev_last,
|
||||
})
|
||||
|
||||
per_location.sort(key=lambda r: r["event_count"], reverse=True)
|
||||
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"total_events": total_events,
|
||||
"peak_pvs": peak_pvs,
|
||||
"peak_pvs_at": peak_pvs_at,
|
||||
"peak_pvs_location_id": peak_pvs_location_id,
|
||||
"peak_pvs_location_name": peak_pvs_location_name,
|
||||
"last_event": last_event,
|
||||
"false_trigger_count": false_trigger_count,
|
||||
"per_location": per_location,
|
||||
"vibration_location_count": len(locations),
|
||||
}
|
||||
|
||||
|
||||
# ── Stats helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _empty_stats() -> dict:
|
||||
return {
|
||||
"event_count": 0,
|
||||
"peak_pvs": None,
|
||||
"peak_pvs_at": None,
|
||||
"peak_pvs_serial": None,
|
||||
"last_event": None,
|
||||
"false_trigger_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def _compute_stats(events: list[dict]) -> dict:
|
||||
"""Roll up summary stats from a merged event list. Cheap O(N) pass.
|
||||
|
||||
The "Overall Peak" stat (peak_pvs) EXCLUDES events flagged as false
|
||||
triggers — operators care about the highest REAL event, not the
|
||||
biggest sensor glitch. false_trigger_count still includes them so
|
||||
operators can see how many were filtered out. last_event uses
|
||||
every event regardless (it's about activity recency, not magnitude).
|
||||
"""
|
||||
if not events:
|
||||
return _empty_stats()
|
||||
|
||||
peak_pvs = None
|
||||
peak_pvs_at = None
|
||||
peak_pvs_serial = None
|
||||
last_event = None
|
||||
false_trigger_count = 0
|
||||
|
||||
for ev in events:
|
||||
is_false_trigger = bool(ev.get("false_trigger"))
|
||||
if is_false_trigger:
|
||||
false_trigger_count += 1
|
||||
|
||||
# Peak calculation: skip flagged false triggers.
|
||||
if not is_false_trigger:
|
||||
pvs = ev.get("peak_vector_sum")
|
||||
if pvs is not None and (peak_pvs is None or pvs > peak_pvs):
|
||||
peak_pvs = pvs
|
||||
peak_pvs_at = ev.get("timestamp")
|
||||
peak_pvs_serial = ev.get("serial")
|
||||
|
||||
ts = ev.get("timestamp")
|
||||
if ts and (last_event is None or ts > last_event):
|
||||
last_event = ts
|
||||
|
||||
return {
|
||||
"event_count": len(events),
|
||||
"peak_pvs": peak_pvs,
|
||||
"peak_pvs_at": peak_pvs_at,
|
||||
"peak_pvs_serial": peak_pvs_serial,
|
||||
"last_event": last_event,
|
||||
"false_trigger_count": false_trigger_count,
|
||||
}
|
||||
+136
-11
@@ -1,9 +1,77 @@
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db_session
|
||||
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||
|
||||
# Tiny module-level cache: /api/status-snapshot is polled every 10s by the
|
||||
# dashboard, and we don't want to hammer SFM with one /db/units roundtrip per
|
||||
# call. 15s TTL keeps the cache mostly hot, with occasional refreshes.
|
||||
_SFM_CACHE_TTL_SECONDS = 15.0
|
||||
_sfm_cache_lock = threading.Lock()
|
||||
_sfm_cache: dict = {"fetched_at": 0.0, "data": None, "reachable": False}
|
||||
|
||||
|
||||
def _parse_sfm_timestamp(ts_str: Optional[str]) -> Optional[datetime]:
|
||||
"""SFM /db/units returns naive ISO timestamps (no tz suffix). Treat them
|
||||
as UTC, mirroring how the watcher heartbeat stores Emitter.last_seen."""
|
||||
if not ts_str:
|
||||
return None
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
return ts
|
||||
|
||||
|
||||
def fetch_sfm_unit_last_seen() -> tuple[dict[str, datetime], bool]:
|
||||
"""Return ({serial: last_seen_utc}, sfm_reachable).
|
||||
|
||||
Cached for _SFM_CACHE_TTL_SECONDS. On any HTTP error returns ({}, False)
|
||||
so callers transparently fall back to the watcher-heartbeat path.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
with _sfm_cache_lock:
|
||||
if _sfm_cache["data"] is not None and (now - _sfm_cache["fetched_at"]) < _SFM_CACHE_TTL_SECONDS:
|
||||
return _sfm_cache["data"], _sfm_cache["reachable"]
|
||||
|
||||
data: dict[str, datetime] = {}
|
||||
reachable = False
|
||||
try:
|
||||
with httpx.Client(timeout=4.0) as client:
|
||||
resp = client.get(f"{SFM_BASE_URL}/db/units")
|
||||
resp.raise_for_status()
|
||||
payload = resp.json() or []
|
||||
for row in payload:
|
||||
serial = row.get("serial")
|
||||
ts = _parse_sfm_timestamp(row.get("last_seen"))
|
||||
if serial and ts is not None:
|
||||
data[serial] = ts
|
||||
reachable = True
|
||||
except httpx.HTTPError as e:
|
||||
log.warning("SFM /db/units unreachable for status snapshot: %s", e)
|
||||
except Exception as e: # noqa: BLE001 — defensive against malformed payload
|
||||
log.warning("SFM /db/units parse error: %s", e)
|
||||
|
||||
with _sfm_cache_lock:
|
||||
_sfm_cache["fetched_at"] = now
|
||||
_sfm_cache["data"] = data
|
||||
_sfm_cache["reachable"] = reachable
|
||||
return data, reachable
|
||||
|
||||
|
||||
def ensure_utc(dt):
|
||||
if dt is None:
|
||||
@@ -69,6 +137,11 @@ def emit_status_snapshot():
|
||||
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
||||
|
||||
# SFM event-forwards are now the primary "last seen" signal for
|
||||
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
|
||||
# or hasn't seen a serial, we fall back to Emitter.last_seen.
|
||||
sfm_last_seen_map, sfm_reachable = fetch_sfm_unit_last_seen()
|
||||
|
||||
units = {}
|
||||
|
||||
# --- Merge roster entries first ---
|
||||
@@ -86,30 +159,63 @@ def emit_status_snapshot():
|
||||
age = "N/A"
|
||||
last_seen = None
|
||||
fname = ""
|
||||
elif getattr(r, 'allocated', False) and not r.deployed:
|
||||
# Allocated: staged for an upcoming job, not yet physically deployed
|
||||
status = "Allocated"
|
||||
age = "N/A"
|
||||
last_seen = None
|
||||
fname = ""
|
||||
else:
|
||||
if e:
|
||||
last_seen = ensure_utc(e.last_seen)
|
||||
# RECALCULATE status based on current time, not stored value
|
||||
device_type = r.device_type or "seismograph"
|
||||
emitter_last_seen = ensure_utc(e.last_seen) if e else None
|
||||
fname = e.last_file if e else ""
|
||||
|
||||
# SFM-primary, heartbeat-backup logic — only for seismographs.
|
||||
# (SLMs / modems aren't forwarded into SFM's events store.)
|
||||
sfm_last_seen = sfm_last_seen_map.get(unit_id) if device_type == "seismograph" else None
|
||||
|
||||
if sfm_last_seen and emitter_last_seen:
|
||||
# Both sources reported — use whichever is more recent.
|
||||
if sfm_last_seen >= emitter_last_seen:
|
||||
last_seen = sfm_last_seen
|
||||
last_seen_source = "sfm"
|
||||
else:
|
||||
last_seen = emitter_last_seen
|
||||
last_seen_source = "heartbeat"
|
||||
elif sfm_last_seen:
|
||||
last_seen = sfm_last_seen
|
||||
last_seen_source = "sfm"
|
||||
elif emitter_last_seen:
|
||||
last_seen = emitter_last_seen
|
||||
# If SFM was reachable but doesn't have this serial, it
|
||||
# means the unit is calling home to the watcher but not
|
||||
# being forwarded — still a working state for now.
|
||||
last_seen_source = "heartbeat"
|
||||
else:
|
||||
last_seen = None
|
||||
last_seen_source = "none"
|
||||
|
||||
if last_seen is not None:
|
||||
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||
age = format_age(last_seen)
|
||||
fname = e.last_file
|
||||
else:
|
||||
# Rostered but no emitter data
|
||||
status = "Missing"
|
||||
last_seen = None
|
||||
age = "N/A"
|
||||
fname = ""
|
||||
|
||||
units[unit_id] = {
|
||||
"id": unit_id,
|
||||
"status": status,
|
||||
"age": age,
|
||||
"last": last_seen.isoformat() if last_seen else None,
|
||||
"last_seen_source": last_seen_source,
|
||||
"sfm_reachable": sfm_reachable,
|
||||
"fname": fname,
|
||||
"deployed": r.deployed,
|
||||
"note": r.note or "",
|
||||
"retired": r.retired,
|
||||
"out_for_calibration": r.out_for_calibration or False,
|
||||
"allocated": getattr(r, 'allocated', False) or False,
|
||||
"allocated_to_project_id": getattr(r, 'allocated_to_project_id', None) or "",
|
||||
# Device type and type-specific fields
|
||||
"device_type": r.device_type or "seismograph",
|
||||
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||
@@ -128,19 +234,30 @@ def emit_status_snapshot():
|
||||
# --- Add unexpected emitter-only units ---
|
||||
for unit_id, e in emitters.items():
|
||||
if unit_id not in roster:
|
||||
last_seen = ensure_utc(e.last_seen)
|
||||
emitter_last_seen = ensure_utc(e.last_seen)
|
||||
sfm_last_seen = sfm_last_seen_map.get(unit_id)
|
||||
if sfm_last_seen and (not emitter_last_seen or sfm_last_seen >= emitter_last_seen):
|
||||
last_seen = sfm_last_seen
|
||||
last_seen_source = "sfm"
|
||||
else:
|
||||
last_seen = emitter_last_seen
|
||||
last_seen_source = "heartbeat"
|
||||
# RECALCULATE status for unknown units too
|
||||
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||
units[unit_id] = {
|
||||
"id": unit_id,
|
||||
"status": status,
|
||||
"age": format_age(last_seen),
|
||||
"last": last_seen.isoformat(),
|
||||
"last": last_seen.isoformat() if last_seen else None,
|
||||
"last_seen_source": last_seen_source,
|
||||
"sfm_reachable": sfm_reachable,
|
||||
"fname": e.last_file,
|
||||
"deployed": False, # default
|
||||
"note": "",
|
||||
"retired": False,
|
||||
"out_for_calibration": False,
|
||||
"allocated": False,
|
||||
"allocated_to_project_id": "",
|
||||
# Device type and type-specific fields (defaults for unknown units)
|
||||
"device_type": "seismograph", # default
|
||||
"last_calibrated": None,
|
||||
@@ -182,6 +299,7 @@ def emit_status_snapshot():
|
||||
unit_data["status"] = paired_unit.get("status", "Missing")
|
||||
unit_data["age"] = paired_unit.get("age", "N/A")
|
||||
unit_data["last"] = paired_unit.get("last")
|
||||
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
|
||||
unit_data["derived_from"] = paired_unit_id
|
||||
|
||||
# Separate buckets for UI
|
||||
@@ -192,7 +310,12 @@ def emit_status_snapshot():
|
||||
|
||||
benched_units = {
|
||||
uid: u for uid, u in units.items()
|
||||
if not u["retired"] and not u["out_for_calibration"] and not u["deployed"] and uid not in ignored
|
||||
if not u["retired"] and not u["out_for_calibration"] and not u["allocated"] and not u["deployed"] and uid not in ignored
|
||||
}
|
||||
|
||||
allocated_units = {
|
||||
uid: u for uid, u in units.items()
|
||||
if not u["retired"] and not u["out_for_calibration"] and u["allocated"] and not u["deployed"] and uid not in ignored
|
||||
}
|
||||
|
||||
retired_units = {
|
||||
@@ -216,13 +339,15 @@ def emit_status_snapshot():
|
||||
"units": units,
|
||||
"active": active_units,
|
||||
"benched": benched_units,
|
||||
"allocated": allocated_units,
|
||||
"retired": retired_units,
|
||||
"out_for_calibration": out_for_calibration_units,
|
||||
"unknown": unknown_units,
|
||||
"summary": {
|
||||
"total": len(active_units) + len(benched_units),
|
||||
"total": len(active_units) + len(benched_units) + len(allocated_units),
|
||||
"active": len(active_units),
|
||||
"benched": len(benched_units),
|
||||
"allocated": len(allocated_units),
|
||||
"retired": len(retired_units),
|
||||
"out_for_calibration": len(out_for_calibration_units),
|
||||
"unknown": len(unknown_units),
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/* event-modal.js — shared event-detail modal.
|
||||
*
|
||||
* Used by:
|
||||
* - /sfm (admin Events tab)
|
||||
* - /projects/{p}/nrl/{l} (project-location Events tab)
|
||||
* - /unit/{id} (unit-detail SFM Events table)
|
||||
*
|
||||
* Pages must include partials/event_detail_modal.html in the body
|
||||
* before this script is loaded.
|
||||
*
|
||||
* Public API:
|
||||
* showEventDetail(eventId)
|
||||
* Open the modal and fetch /api/sfm/db/events/{id}/sidecar to
|
||||
* populate the rich BW report fields (peaks, ZC freq, sensor
|
||||
* self-check, device info, etc.) into a tabbed/sectioned view.
|
||||
*
|
||||
* closeEventDetailModal()
|
||||
* Close the modal.
|
||||
*
|
||||
* Notes:
|
||||
* - Fetches sidecar live from SFM via terra-view's /api/sfm proxy.
|
||||
* - Renders gracefully when the sidecar lacks a bw_report block
|
||||
* (older events forwarded before the _ASCII.TXT pairing fix).
|
||||
* - All functions are global on window so inline onclick handlers
|
||||
* can reach them across all three host pages.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
const MODAL_ID = 'event-detail-modal';
|
||||
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _fmt(v, digits = 4, suffix = '') {
|
||||
if (v == null || (typeof v === 'number' && Number.isNaN(v))) return '—';
|
||||
if (typeof v === 'number') {
|
||||
return v.toFixed(digits) + (suffix ? ` ${suffix}` : '');
|
||||
}
|
||||
return _esc(v) + (suffix ? ` ${suffix}` : '');
|
||||
}
|
||||
|
||||
function _ppvClass(v) {
|
||||
if (v == null) return 'text-gray-400';
|
||||
if (v < 0.5) return 'text-green-600 dark:text-green-400';
|
||||
if (v < 2.0) return 'text-amber-600 dark:text-amber-400';
|
||||
return 'text-red-600 dark:text-red-400 font-semibold';
|
||||
}
|
||||
|
||||
function _kvCard(label, value, options = {}) {
|
||||
// Single key-value tile. `value` is pre-rendered HTML (or text).
|
||||
const colorCls = options.colorCls || '';
|
||||
const valCls = `font-mono font-semibold ${colorCls}`;
|
||||
return `<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">${_esc(label)}</div>
|
||||
<div class="${valCls} mt-1">${value}</div>
|
||||
${options.sub ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">${options.sub}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _sectionHeader(title, sub) {
|
||||
return `<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-5 first:mt-0">
|
||||
${_esc(title)}${sub ? ` <span class="text-xs text-gray-400 normal-case font-normal ml-2">${_esc(sub)}</span>` : ''}
|
||||
</h4>`;
|
||||
}
|
||||
|
||||
// ── Section renderers ────────────────────────────────────────────
|
||||
|
||||
function _renderEventHeader(s) {
|
||||
const ev = s.event || {};
|
||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||
return `<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
|
||||
<div><span class="text-gray-500">Serial</span> <span class="font-mono font-semibold text-seismo-orange ml-1">${_esc(ev.serial)}</span></div>
|
||||
<div><span class="text-gray-500">Timestamp</span> <span class="font-medium ml-1">${ts}</span></div>
|
||||
<div><span class="text-gray-500">Record Type</span> <span class="font-medium ml-1">${_esc(ev.record_type || '—')}</span></div>
|
||||
<div><span class="text-gray-500">Sample Rate</span> <span class="font-medium ml-1">${ev.sample_rate ?? '—'} sps</span></div>
|
||||
<div><span class="text-gray-500">Rec Time</span> <span class="font-medium ml-1">${ev.rectime_seconds != null ? ev.rectime_seconds + ' s' : '—'}</span></div>
|
||||
<div><span class="text-gray-500">Waveform Key</span> <span class="font-mono text-xs ml-1">${_esc(ev.waveform_key || '—')}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderProjectInfo(s) {
|
||||
// The "user notes" metadata the operator typed into the BW device.
|
||||
// These are the strings the future metadata-driven parser will use.
|
||||
const p = s.project_info || {};
|
||||
return `<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<div><span class="text-gray-500">Project</span> <span class="font-medium ml-1">${_esc(p.project || '—')}</span></div>
|
||||
<div><span class="text-gray-500">Client</span> <span class="font-medium ml-1">${_esc(p.client || '—')}</span></div>
|
||||
<div><span class="text-gray-500">Operator</span> <span class="font-medium ml-1">${_esc(p.operator || '—')}</span></div>
|
||||
<div><span class="text-gray-500">Sensor Location</span> <span class="font-medium ml-1">${_esc(p.sensor_location || '—')}</span></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2 italic">
|
||||
Values are as typed into the seismograph at session start — not the terra-view project/location assignment.
|
||||
</p>`;
|
||||
}
|
||||
|
||||
function _renderPeakValues(s) {
|
||||
// Prefer bw_report.peaks for richer per-channel data; fall back to peak_values.
|
||||
const bwPeaks = (s.bw_report && s.bw_report.peaks) || null;
|
||||
const pv = s.peak_values || {};
|
||||
|
||||
const tran = bwPeaks ? bwPeaks.tran?.ppv_ips : pv.transverse;
|
||||
const vert = bwPeaks ? bwPeaks.vert?.ppv_ips : pv.vertical;
|
||||
const lng = bwPeaks ? bwPeaks.long?.ppv_ips : pv.longitudinal;
|
||||
const pvs = bwPeaks ? bwPeaks.vector_sum?.ips : pv.vector_sum;
|
||||
const pvsAt = bwPeaks ? bwPeaks.vector_sum?.time_s : null;
|
||||
|
||||
return `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
${_kvCard('Transverse', `<span class="${_ppvClass(tran)}">${_fmt(tran, 4)}</span>`, { sub: 'in/s' })}
|
||||
${_kvCard('Vertical', `<span class="${_ppvClass(vert)}">${_fmt(vert, 4)}</span>`, { sub: 'in/s' })}
|
||||
${_kvCard('Longitudinal', `<span class="${_ppvClass(lng)}">${_fmt(lng, 4)}</span>`, { sub: 'in/s' })}
|
||||
${_kvCard('Peak Vector Sum', `<span class="${_ppvClass(pvs)} text-base">${_fmt(pvs, 4)}</span>`, {
|
||||
sub: pvsAt != null ? `in/s @ t=${_fmt(pvsAt, 2)}s` : 'in/s',
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderMic(s) {
|
||||
const mic = (s.bw_report && s.bw_report.mic) || null;
|
||||
const pv = s.peak_values || {};
|
||||
|
||||
if (!mic && pv.mic_psi == null) return '';
|
||||
|
||||
const dbl = mic?.pspl_dbl;
|
||||
const psi = pv.mic_psi;
|
||||
const zcHz = mic?.zc_freq_hz;
|
||||
const tPk = mic?.time_of_peak_s;
|
||||
const wt = mic?.weighting;
|
||||
|
||||
return `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
${_kvCard('Peak Mic dB(L)', _fmt(dbl, 1), { sub: wt || '' })}
|
||||
${_kvCard('Peak Mic psi', _fmt(psi, 4))}
|
||||
${_kvCard('ZC Frequency', _fmt(zcHz, 1, 'Hz'))}
|
||||
${_kvCard('Time of Peak', tPk != null ? _fmt(tPk, 2, 's') : '—')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _sensorRow(label, ch) {
|
||||
if (!ch) {
|
||||
return `<tr><td class="px-3 py-2 text-sm font-medium">${_esc(label)}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-400" colspan="3">—</td></tr>`;
|
||||
}
|
||||
const result = ch.result || '—';
|
||||
const resultCls = result === 'Passed'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: (result === 'Failed' ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-500');
|
||||
|
||||
// Geo channels have freq + ratio; mic has freq + amplitude.
|
||||
const rightCol = (ch.amplitude_mv != null)
|
||||
? `<td class="px-3 py-2 text-sm font-mono">${_fmt(ch.amplitude_mv, 1, 'mV')}</td>`
|
||||
: `<td class="px-3 py-2 text-sm font-mono">${ch.ratio != null ? ch.ratio.toFixed(1) + ' ratio' : '—'}</td>`;
|
||||
|
||||
return `<tr>
|
||||
<td class="px-3 py-2 text-sm font-medium">${_esc(label)}</td>
|
||||
<td class="px-3 py-2 text-sm font-mono">${_fmt(ch.freq_hz, 1, 'Hz')}</td>
|
||||
${rightCol}
|
||||
<td class="px-3 py-2 text-sm ${resultCls}">${_esc(result)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function _renderSensorCheck(s) {
|
||||
const sc = s.bw_report && s.bw_report.sensor_check;
|
||||
if (!sc) return '';
|
||||
return `<table class="w-full text-left rounded overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Channel</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Frequency</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Amplitude/Ratio</th>
|
||||
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-slate-800">
|
||||
${_sensorRow('Transverse', sc.tran)}
|
||||
${_sensorRow('Vertical', sc.vert)}
|
||||
${_sensorRow('Longitudinal', sc.long)}
|
||||
${_sensorRow('Microphone', sc.mic)}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function _renderDeviceMetadata(s) {
|
||||
const bw = s.bw_report || {};
|
||||
const dev = bw.device || {};
|
||||
const rec = bw.recording || {};
|
||||
return `<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
|
||||
<div><span class="text-gray-500">Firmware</span> <span class="font-mono text-xs ml-1">${_esc(bw.version || '—')}</span></div>
|
||||
<div><span class="text-gray-500">Battery</span> <span class="font-medium ml-1">${dev.battery_volts != null ? dev.battery_volts.toFixed(2) + ' V' : '—'}</span></div>
|
||||
<div><span class="text-gray-500">Calibrated</span> <span class="font-medium ml-1">${_esc(dev.calibration_date || '—')}${dev.calibration_by ? ' (' + _esc(dev.calibration_by) + ')' : ''}</span></div>
|
||||
<div><span class="text-gray-500">Geo Range</span> <span class="font-medium ml-1">${rec.geo_range_ips != null ? rec.geo_range_ips + ' in/s' : '—'}</span></div>
|
||||
<div><span class="text-gray-500">Stop Mode</span> <span class="font-medium ml-1">${_esc(rec.stop_mode || '—')}</span></div>
|
||||
<div><span class="text-gray-500">Units</span> <span class="font-medium ml-1">${_esc(rec.units || '—')}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderFileInfo(s, eventId) {
|
||||
const bw = s.blastware || {};
|
||||
const src = s.source || {};
|
||||
const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null;
|
||||
const canDownloadBinary = !!(bw.available && bw.filename && eventId);
|
||||
|
||||
const downloadButtons = `
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
${canDownloadBinary ? `
|
||||
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/blastware_file"
|
||||
download="${_esc(bw.filename)}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Download Blastware file
|
||||
<span class="text-xs opacity-80 ml-1">(${_esc(bw.filename)}${sizeKb ? `, ${sizeKb} KB` : ''})</span>
|
||||
</a>
|
||||
` : `
|
||||
<span class="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded-lg text-sm cursor-not-allowed">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Blastware file unavailable
|
||||
</span>
|
||||
`}
|
||||
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar"
|
||||
download="${_esc((bw.filename || 'event') + '.sfm.json')}"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 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 text-sm transition-colors">
|
||||
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Download sidecar JSON
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `${downloadButtons}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '—')}</span> ${sizeKb ? `<span class="text-xs text-gray-500 ml-2">(${sizeKb} KB)</span>` : ''}</div>
|
||||
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '—')}</span></div>
|
||||
<div><span class="text-gray-500">Captured at</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}</span></div>
|
||||
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '—')}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
|
||||
window.showEventDetail = async function (eventId) {
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
if (!modal) {
|
||||
console.warn('event-modal: include event_detail_modal.html partial on this page.');
|
||||
return;
|
||||
}
|
||||
modal.classList.remove('hidden');
|
||||
document.getElementById(MODAL_ID + '-title').textContent = 'Event Detail';
|
||||
document.getElementById(MODAL_ID + '-content').innerHTML = `
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>
|
||||
Loading event detail…
|
||||
</div>`;
|
||||
|
||||
let s;
|
||||
try {
|
||||
const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`);
|
||||
if (!r.ok) {
|
||||
throw new Error('HTTP ' + r.status + ' fetching sidecar');
|
||||
}
|
||||
s = await r.json();
|
||||
} catch (e) {
|
||||
document.getElementById(MODAL_ID + '-content').innerHTML = `
|
||||
<div class="text-center py-8 text-red-500 text-sm">
|
||||
Failed to load event detail: ${_esc(e.message)}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const ev = s.event || {};
|
||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '';
|
||||
document.getElementById(MODAL_ID + '-title').textContent =
|
||||
`Event — ${ev.serial || '?'} @ ${ts}`;
|
||||
|
||||
const hasReport = !!s.bw_report;
|
||||
const reportNote = hasReport
|
||||
? ''
|
||||
: `<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 text-sm text-amber-800 dark:text-amber-300 mb-4">
|
||||
<strong>No BW ASCII report paired with this event.</strong>
|
||||
Older events forwarded before the watcher's <code class="font-mono text-xs">_ASCII.TXT</code> pairing fix landed lack this data.
|
||||
PPV is still available from the binary event file.
|
||||
</div>`;
|
||||
|
||||
document.getElementById(MODAL_ID + '-content').innerHTML = `
|
||||
${reportNote}
|
||||
|
||||
${_sectionHeader('Event')}
|
||||
${_renderEventHeader(s)}
|
||||
|
||||
${_sectionHeader('Project Info', '(operator-typed at session start)')}
|
||||
${_renderProjectInfo(s)}
|
||||
|
||||
${_sectionHeader('Peak Particle Velocity')}
|
||||
${_renderPeakValues(s)}
|
||||
|
||||
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
|
||||
${_sectionHeader('Microphone')}
|
||||
${_renderMic(s)}
|
||||
` : ''}
|
||||
|
||||
${hasReport ? `
|
||||
${_sectionHeader('Sensor Self-Check')}
|
||||
${_renderSensorCheck(s)}
|
||||
|
||||
${_sectionHeader('Device & Recording Metadata')}
|
||||
${_renderDeviceMetadata(s)}
|
||||
` : ''}
|
||||
|
||||
${_sectionHeader('Source File')}
|
||||
${_renderFileInfo(s, eventId)}
|
||||
`;
|
||||
};
|
||||
|
||||
window.closeEventDetailModal = function () {
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
if (modal) modal.classList.add('hidden');
|
||||
};
|
||||
|
||||
// Close on Escape.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') window.closeEventDetailModal();
|
||||
});
|
||||
})();
|
||||
@@ -73,10 +73,16 @@ def jinja_log_tail_display(s):
|
||||
return str(s)
|
||||
|
||||
|
||||
def jinja_local_datetime_input(dt):
|
||||
"""Jinja filter: format UTC datetime as local YYYY-MM-DDTHH:MM for <input type='datetime-local'>."""
|
||||
return format_local_datetime(dt, "%Y-%m-%dT%H:%M")
|
||||
|
||||
|
||||
# Register Jinja filters and globals
|
||||
templates.env.filters["local_datetime"] = jinja_local_datetime
|
||||
templates.env.filters["local_time"] = jinja_local_time
|
||||
templates.env.filters["local_date"] = jinja_local_date
|
||||
templates.env.filters["local_datetime_input"] = jinja_local_datetime_input
|
||||
templates.env.filters["fromjson"] = jinja_fromjson
|
||||
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
|
||||
templates.env.globals["get_user_timezone"] = get_user_timezone
|
||||
|
||||
@@ -10,9 +10,11 @@ services:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- ENVIRONMENT=production
|
||||
- SLMM_BASE_URL=http://host.docker.internal:8100
|
||||
- SFM_BASE_URL=http://sfm:8200
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- slmm
|
||||
- sfm
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
@@ -44,5 +46,25 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# --- SFM (Seismo Fleet Manager) ---
|
||||
sfm:
|
||||
build:
|
||||
context: ../seismo-relay
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8200:8200"
|
||||
volumes:
|
||||
- ../seismo-relay/sfm/data:/app/sfm/data
|
||||
- ../seismo-relay/bridges/captures:/app/bridges/captures
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- PORT=8200
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
volumes:
|
||||
data:
|
||||
|
||||
@@ -8,3 +8,4 @@ aiofiles==23.2.1
|
||||
Pillow==10.1.0
|
||||
httpx==0.25.2
|
||||
openpyxl==3.1.2
|
||||
rapidfuzz==3.10.1
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Metadata Backfill - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-6">
|
||||
<nav class="flex items-center space-x-2 text-sm">
|
||||
<a href="/settings" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
<svg class="w-4 h-4 text-gray-400" 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"></path>
|
||||
</svg>
|
||||
<span class="text-gray-900 dark:text-white font-medium">Metadata Backfill</span>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Backfill from event metadata</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Auto-create projects, locations, and unit assignments from operator-typed metadata on Blastware events.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary card (populated after scan) -->
|
||||
<div id="summary-card" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
|
||||
<div id="summary-initial">
|
||||
<div class="text-center py-8">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Scan SFM events</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mb-6 max-w-xl mx-auto">
|
||||
Reads all events from SFM, clusters them by serial & time, matches the
|
||||
operator-typed metadata against your existing projects, and proposes
|
||||
<strong>Project</strong> / <strong>Location</strong> / <strong>UnitAssignment</strong>
|
||||
chains to create.
|
||||
</p>
|
||||
<button onclick="runScan(false)"
|
||||
id="initial-scan-btn"
|
||||
class="px-6 py-3 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium transition-colors">
|
||||
↻ Run scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="summary-results" class="hidden">
|
||||
<div class="flex items-start justify-between mb-4 flex-wrap gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Scan summary</h2>
|
||||
<p id="summary-scanned-at" class="text-xs text-gray-500 dark:text-gray-400 mt-1"></p>
|
||||
</div>
|
||||
<button onclick="runScan(true)"
|
||||
class="px-3 py-1.5 text-sm 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">
|
||||
↻ Re-scan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI tiles -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Events scanned</span>
|
||||
<span id="kpi-scanned" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Already attributed</span>
|
||||
<span id="kpi-already" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">inside existing assignments</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pending review</span>
|
||||
<span id="kpi-pending" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">clusters to attribute</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Conflicts</span>
|
||||
<span id="kpi-conflicts" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">need manual reconciliation</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- One-click bulk apply -->
|
||||
<div id="bulk-apply-card" class="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4 mb-4 hidden">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-6 h-6 text-seismo-orange shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Bulk-apply <span id="bulk-applicable-count">0</span> high-confidence cluster(s)
|
||||
</h3>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
Apply every cluster scored <strong>high confidence</strong> with no blocking conflicts.
|
||||
Will create <span id="bulk-stats" class="font-medium">—</span>.
|
||||
Medium and low confidence clusters remain in the list below for individual review.
|
||||
</p>
|
||||
<button onclick="applyBulkHighConfidence()"
|
||||
class="px-5 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium transition-colors">
|
||||
Apply all high-confidence
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 italic">
|
||||
Each cluster below shows the operator-typed metadata, what would be created or matched, and the proposed
|
||||
assignment date window. Click <em>Apply</em> to attribute that cluster, <em>Skip</em> to ignore it (won't reappear),
|
||||
or <em>Edit</em> to rename before applying.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cluster list -->
|
||||
<div id="cluster-list" class="space-y-3"></div>
|
||||
|
||||
<!-- Apply progress toast -->
|
||||
<div id="apply-toast" class="hidden fixed bottom-6 right-6 bg-white dark:bg-slate-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 p-4 z-50 max-w-md">
|
||||
<div class="flex items-center gap-3">
|
||||
<div id="toast-icon" class="shrink-0">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange"></div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p id="toast-message" class="text-sm font-medium text-gray-900 dark:text-white">Applying…</p>
|
||||
<p id="toast-sub" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared event-detail modal (Preview event button uses it) -->
|
||||
{% include 'partials/event_detail_modal.html' %}
|
||||
<script src="/static/event-modal.js"></script>
|
||||
|
||||
<script>
|
||||
let _scanData = null;
|
||||
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
function _fmtDateTime(iso) {
|
||||
if (!iso) return '—';
|
||||
return iso.slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
function _confidenceBadge(c) {
|
||||
const map = {
|
||||
high: { cls: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: '🟢', label: 'high' },
|
||||
medium: { cls: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', icon: '🟡', label: 'medium' },
|
||||
low: { cls: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', icon: '🔴', label: 'low' },
|
||||
};
|
||||
const e = map[c] || map.low;
|
||||
return `<span class="px-2 py-0.5 rounded text-xs font-medium ${e.cls}">${e.icon} ${e.label}</span>`;
|
||||
}
|
||||
|
||||
function _matchPill(match, score, suggestedName, existingName) {
|
||||
if (match === 'exact') {
|
||||
return `<span class="font-medium text-green-700 dark:text-green-400">✓ Matches existing: <em>${_esc(existingName || suggestedName)}</em></span>`;
|
||||
}
|
||||
if (match === 'fuzzy') {
|
||||
return `<span class="font-medium text-amber-700 dark:text-amber-400">≈ Fuzzy match (${(score*100).toFixed(0)}%): <em>${_esc(existingName)}</em></span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">(your value: "${_esc(suggestedName)}")</span>`;
|
||||
}
|
||||
if (match === 'ambiguous') {
|
||||
return `<span class="font-medium text-yellow-700 dark:text-yellow-400">? Ambiguous — multiple matches</span>`;
|
||||
}
|
||||
return `<span class="font-medium text-seismo-orange">+ Create new: <em>${_esc(suggestedName)}</em></span>`;
|
||||
}
|
||||
|
||||
// Compact "hint" line under each typeahead input, showing what the parser
|
||||
// thinks the current value will do (match existing vs create new).
|
||||
function _matchHint(match, score, existingName, suggestedName) {
|
||||
if (match === 'exact') {
|
||||
return `<span class="text-green-700 dark:text-green-400">✓ matches existing</span>`;
|
||||
}
|
||||
if (match === 'fuzzy') {
|
||||
return `<span class="text-amber-700 dark:text-amber-400">≈ fuzzy match to "${_esc(existingName)}" (${(score*100).toFixed(0)}%)</span>`;
|
||||
}
|
||||
if (match === 'ambiguous') {
|
||||
return `<span class="text-yellow-700 dark:text-yellow-400">? ambiguous — pick from dropdown</span>`;
|
||||
}
|
||||
return `<span class="text-seismo-orange">+ will create new</span>`;
|
||||
}
|
||||
|
||||
// ── Typeahead ────────────────────────────────────────────────────────────
|
||||
// Per-cluster project + location inputs with debounced typeahead search.
|
||||
// Selecting a result writes the existing entity's id into the hidden
|
||||
// project_id / location_id input; clearing-and-typing falls back to
|
||||
// "create new" semantics.
|
||||
|
||||
let _typeaheadDebounce = null;
|
||||
|
||||
function onTypeaheadInput(e, fieldKind) {
|
||||
// fieldKind ∈ {'project', 'location'}
|
||||
const inp = e.target;
|
||||
const cid = inp.dataset.clusterId;
|
||||
// Clear the "id" hidden input — operator is typing freely now.
|
||||
const hidden = document.querySelector(`input[type="hidden"][data-cluster-id="${cid}"][data-field="${fieldKind}_id"]`);
|
||||
if (hidden) hidden.value = '';
|
||||
// Debounce the search.
|
||||
if (_typeaheadDebounce) clearTimeout(_typeaheadDebounce);
|
||||
_typeaheadDebounce = setTimeout(() => _fetchTypeahead(inp, fieldKind), 150);
|
||||
}
|
||||
|
||||
function onTypeaheadFocus(e, fieldKind) {
|
||||
_fetchTypeahead(e.target, fieldKind);
|
||||
}
|
||||
|
||||
function onTypeaheadBlur(e) {
|
||||
// Delayed hide so a click on the dropdown can register first.
|
||||
const dropdown = e.target.parentElement.querySelector('.typeahead-dropdown');
|
||||
if (dropdown) {
|
||||
setTimeout(() => dropdown.classList.add('hidden'), 150);
|
||||
}
|
||||
}
|
||||
|
||||
async function _fetchTypeahead(input, fieldKind) {
|
||||
const dropdown = input.parentElement.querySelector('.typeahead-dropdown');
|
||||
if (!dropdown) return;
|
||||
const q = input.value.trim();
|
||||
const cid = input.dataset.clusterId;
|
||||
|
||||
let url;
|
||||
if (fieldKind === 'project') {
|
||||
url = `/api/admin/metadata_backfill/projects_search?q=${encodeURIComponent(q)}`;
|
||||
} else {
|
||||
// For locations, scope to the currently-chosen project (if any).
|
||||
const projectIdInput = document.querySelector(`input[type="hidden"][data-cluster-id="${cid}"][data-field="project_id"]`);
|
||||
const projectId = projectIdInput ? projectIdInput.value : '';
|
||||
if (!projectId) {
|
||||
// Operator hasn't picked an existing project — there are no
|
||||
// existing locations to match against (location is implicitly
|
||||
// "create new" inside a new project).
|
||||
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
${q ? `+ Will create new: <strong>"${_esc(q)}"</strong>` : 'Pick a project first, or type a new location name.'}
|
||||
</div>`;
|
||||
dropdown.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
url = `/api/admin/metadata_backfill/locations_search?project_id=${encodeURIComponent(projectId)}&q=${encodeURIComponent(q)}`;
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
data = await r.json();
|
||||
} catch (err) {
|
||||
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-red-500">Search failed: ${_esc(err.message)}</div>`;
|
||||
dropdown.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const items = [];
|
||||
for (const m of (data.matches || [])) {
|
||||
items.push({ kind: 'match', payload: m });
|
||||
}
|
||||
if (data.create_new && data.create_new.label) {
|
||||
items.push({ kind: 'create_new', label: data.create_new.label, name: q });
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">No matches. Type to create.</div>`;
|
||||
dropdown.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.innerHTML = items.map((it, idx) => {
|
||||
if (it.kind === 'match') {
|
||||
const m = it.payload;
|
||||
const scoreBadge = m.score >= 0.99
|
||||
? '<span class="text-xs text-green-600 dark:text-green-400 ml-2">exact</span>'
|
||||
: `<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">${(m.score*100).toFixed(0)}%</span>`;
|
||||
const meta = [];
|
||||
if (fieldKind === 'project') {
|
||||
if (m.project_number) meta.push(_esc(m.project_number));
|
||||
if (m.client_name) meta.push(_esc(m.client_name));
|
||||
if (m.location_count > 0) meta.push(`${m.location_count} location${m.location_count === 1 ? '' : 's'}`);
|
||||
} else {
|
||||
if (m.address) meta.push(_esc(m.address));
|
||||
}
|
||||
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
|
||||
return `<button type="button"
|
||||
onmousedown="event.preventDefault()"
|
||||
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '${_esc(m.id)}', ${JSON.stringify(m.name)})"
|
||||
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">${_esc(m.name)}${scoreBadge}</div>
|
||||
${metaLine}
|
||||
</button>`;
|
||||
}
|
||||
return `<button type="button"
|
||||
onmousedown="event.preventDefault()"
|
||||
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '', ${JSON.stringify(it.name)})"
|
||||
class="w-full text-left px-3 py-2 hover:bg-orange-50 dark:hover:bg-orange-900/20 border-t border-gray-200 dark:border-gray-700 text-seismo-orange font-medium text-sm">
|
||||
+ ${_esc(it.label)}
|
||||
</button>`;
|
||||
}).join('');
|
||||
dropdown.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function onTypeaheadPick(event, clusterId, fieldKind, entityId, name) {
|
||||
// entityId is empty string for "create new", or a UUID for matched existing.
|
||||
const inputs = document.querySelectorAll(`input[data-cluster-id="${clusterId}"]`);
|
||||
let textInput = null;
|
||||
let idInput = null;
|
||||
inputs.forEach(i => {
|
||||
if (i.dataset.field === fieldKind) textInput = i;
|
||||
if (i.dataset.field === fieldKind + '_id') idInput = i;
|
||||
});
|
||||
if (textInput) textInput.value = name;
|
||||
if (idInput) idInput.value = entityId;
|
||||
// Hide this dropdown.
|
||||
const dropdown = textInput.parentElement.querySelector('.typeahead-dropdown');
|
||||
if (dropdown) dropdown.classList.add('hidden');
|
||||
|
||||
// If operator just picked a NEW project, clear the location id (forces
|
||||
// operator to pick a location under the new project rather than leaving
|
||||
// a stale id from another project).
|
||||
if (fieldKind === 'project') {
|
||||
const locId = document.querySelector(`input[type="hidden"][data-cluster-id="${clusterId}"][data-field="location_id"]`);
|
||||
if (locId) locId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function runScan(force) {
|
||||
const initial = document.getElementById('summary-initial');
|
||||
const results = document.getElementById('summary-results');
|
||||
const list = document.getElementById('cluster-list');
|
||||
|
||||
initial.classList.add('hidden');
|
||||
results.classList.remove('hidden');
|
||||
list.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Scanning events…</div>';
|
||||
|
||||
document.getElementById('kpi-scanned').textContent = '…';
|
||||
document.getElementById('kpi-already').textContent = '…';
|
||||
document.getElementById('kpi-pending').textContent = '…';
|
||||
document.getElementById('kpi-conflicts').textContent = '…';
|
||||
document.getElementById('bulk-apply-card').classList.add('hidden');
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/admin/metadata_backfill/scan' + (force ? '?force=true' : ''));
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
_scanData = await r.json();
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 text-center text-red-500">Scan failed: ${_esc(e.message)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('kpi-scanned').textContent = _scanData.scanned_event_count.toLocaleString();
|
||||
document.getElementById('kpi-already').textContent = _scanData.already_attributed.toLocaleString();
|
||||
document.getElementById('kpi-pending').textContent = _scanData.pending_count.toLocaleString();
|
||||
document.getElementById('kpi-conflicts').textContent = _scanData.blocking_conflict_count.toLocaleString();
|
||||
document.getElementById('summary-scanned-at').textContent =
|
||||
'Scanned ' + new Date(_scanData.scanned_at * 1000).toLocaleString();
|
||||
|
||||
// Configure bulk-apply card.
|
||||
const highApplicable = _scanData.by_confidence.high.filter(s => !s.blocking_conflict);
|
||||
const newProjects = new Set(), newLocations = new Set();
|
||||
for (const s of highApplicable) {
|
||||
if (s.project_match === 'create_new') newProjects.add(s.project_suggested_name.toLowerCase());
|
||||
if (s.location_match === 'create_new') newLocations.add(s.location_suggested_name.toLowerCase());
|
||||
}
|
||||
if (highApplicable.length > 0) {
|
||||
document.getElementById('bulk-apply-card').classList.remove('hidden');
|
||||
document.getElementById('bulk-applicable-count').textContent = highApplicable.length;
|
||||
const parts = [];
|
||||
if (newProjects.size > 0) parts.push(`${newProjects.size} project${newProjects.size === 1 ? '' : 's'}`);
|
||||
if (newLocations.size > 0) parts.push(`${newLocations.size} location${newLocations.size === 1 ? '' : 's'}`);
|
||||
parts.push(`${highApplicable.length} assignment${highApplicable.length === 1 ? '' : 's'}`);
|
||||
document.getElementById('bulk-stats').textContent = parts.join(' · ');
|
||||
}
|
||||
|
||||
renderClusterList();
|
||||
}
|
||||
|
||||
function renderClusterList() {
|
||||
const list = document.getElementById('cluster-list');
|
||||
const all = [
|
||||
..._scanData.by_confidence.high,
|
||||
..._scanData.by_confidence.medium,
|
||||
..._scanData.by_confidence.low,
|
||||
];
|
||||
|
||||
if (all.length === 0) {
|
||||
list.innerHTML = `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">✅ All caught up</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400">Every event in SFM is either attributed to an existing assignment or has been skipped.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = all.map(_renderCluster).join('');
|
||||
}
|
||||
|
||||
function _renderCluster(s) {
|
||||
const spanDays = (new Date(s.last_event_ts) - new Date(s.first_event_ts)) / 86400000;
|
||||
const consistencyNote = s.metadata_consistency < 1.0
|
||||
? `<span class="ml-2 text-xs text-amber-600 dark:text-amber-400" title="Some events in this cluster have slightly different metadata — possibly a typo or mid-stream change.">⚠ ${(s.metadata_consistency*100).toFixed(0)}% consistent</span>`
|
||||
: '';
|
||||
|
||||
const blockingBanner = s.blocking_conflict
|
||||
? `<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 mt-3 text-sm text-red-800 dark:text-red-300">
|
||||
<strong>⚠ Blocking conflict.</strong>
|
||||
${s.conflicts.map(c => `Unit ${_esc(s.serial)} is already assigned to <em>${_esc(c.other_project_name)} / ${_esc(c.other_location_name)}</em> during this window.`).join(' ')}
|
||||
Resolve manually before this cluster can be applied.
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const orphanInputs = s.is_blank_meta
|
||||
? `<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 mt-3">
|
||||
<p class="text-sm text-amber-800 dark:text-amber-300 mb-2"><strong>⚠ Blank metadata.</strong> Operator didn't type project / location for these events. Fill in manually:</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input type="text" placeholder="Project name" data-cluster-id="${_esc(s.cluster_id)}" data-field="project_name"
|
||||
class="px-2 py-1 text-sm border border-amber-300 dark:border-amber-700 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<input type="text" placeholder="Location name" data-cluster-id="${_esc(s.cluster_id)}" data-field="location_name"
|
||||
class="px-2 py-1 text-sm border border-amber-300 dark:border-amber-700 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4" data-cluster-id="${_esc(s.cluster_id)}">
|
||||
<div class="flex items-start justify-between gap-3 mb-3 flex-wrap">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
${_confidenceBadge(s.confidence)}
|
||||
<a href="/unit/${_esc(s.serial)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">${_esc(s.serial)}</a>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">${_fmtDate(s.first_event_ts)} → ${_fmtDate(s.last_event_ts)}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">(${s.event_count} event${s.event_count === 1 ? '' : 's'}, ${spanDays.toFixed(0)}d span)</span>
|
||||
${consistencyNote}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300 mt-2 space-y-2">
|
||||
<!-- Project typeahead -->
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400 w-24 shrink-0 pt-1.5">Project:</span>
|
||||
<div class="flex-1 relative">
|
||||
<input type="text"
|
||||
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||
value="${_esc(s.project_existing_name || s.project_suggested_name)}"
|
||||
data-cluster-id="${_esc(s.cluster_id)}"
|
||||
data-field="project"
|
||||
data-initial-project-id="${_esc(s.project_existing_id || '')}"
|
||||
placeholder="Type to search or create…"
|
||||
oninput="onTypeaheadInput(event, 'project')"
|
||||
onfocus="onTypeaheadFocus(event, 'project')"
|
||||
onblur="onTypeaheadBlur(event)"
|
||||
autocomplete="off">
|
||||
<input type="hidden" data-cluster-id="${_esc(s.cluster_id)}" data-field="project_id" value="${_esc(s.project_existing_id || '')}">
|
||||
<div class="typeahead-dropdown hidden absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto"></div>
|
||||
<div class="text-xs mt-0.5">${_matchHint(s.project_match, s.project_match_score, s.project_existing_name, s.project_suggested_name)}</div>
|
||||
${(s.project_root && s.project_raw && s.project_root !== s.project_raw)
|
||||
? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">↳ stripped trailing "Loc N" suffix; operator typed: <em>"${_esc(s.project_raw)}"</em></div>`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location typeahead -->
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400 w-24 shrink-0 pt-1.5">Location:</span>
|
||||
<div class="flex-1 relative">
|
||||
<input type="text"
|
||||
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||
value="${_esc(s.location_existing_name || s.location_suggested_name)}"
|
||||
data-cluster-id="${_esc(s.cluster_id)}"
|
||||
data-field="location"
|
||||
data-initial-location-id="${_esc(s.location_existing_id || '')}"
|
||||
placeholder="Type to search or create…"
|
||||
oninput="onTypeaheadInput(event, 'location')"
|
||||
onfocus="onTypeaheadFocus(event, 'location')"
|
||||
onblur="onTypeaheadBlur(event)"
|
||||
autocomplete="off">
|
||||
<input type="hidden" data-cluster-id="${_esc(s.cluster_id)}" data-field="location_id" value="${_esc(s.location_existing_id || '')}">
|
||||
<div class="typeahead-dropdown hidden absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto"></div>
|
||||
<div class="text-xs mt-0.5">${_matchHint(s.location_match, s.location_match_score, s.location_existing_name, s.location_suggested_name)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div><span class="text-gray-500 dark:text-gray-400 w-24 inline-block">Assignment:</span> ${_fmtDateTime(s.proposed_assigned_at)} → ${s.proposed_assigned_until ? _fmtDateTime(s.proposed_assigned_until) : '<span class="text-green-700 dark:text-green-400 font-medium">present (active)</span>'}</div>
|
||||
${s.client_raw ? `<div><span class="text-gray-500 dark:text-gray-400 w-24 inline-block">Client:</span> <em>${_esc(s.client_raw)}</em></div>` : ''}
|
||||
</div>
|
||||
${blockingBanner}
|
||||
${orphanInputs}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 shrink-0">
|
||||
<button onclick="showEventDetail('${_esc(s.sample_event_id)}')"
|
||||
class="px-3 py-1.5 text-xs 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">
|
||||
Preview event
|
||||
</button>
|
||||
${s.blocking_conflict
|
||||
? `<button disabled class="px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-400 rounded cursor-not-allowed">Apply</button>`
|
||||
: `<button onclick="applyOne('${_esc(s.cluster_id)}')"
|
||||
class="px-3 py-1.5 text-xs bg-seismo-orange hover:bg-seismo-navy text-white rounded font-medium">
|
||||
Apply
|
||||
</button>`}
|
||||
<button onclick="skipOne('${_esc(s.cluster_id)}')"
|
||||
class="px-3 py-1.5 text-xs 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">
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _gatherOverrides(clusterIds) {
|
||||
// Per-cluster overrides sent to /apply. The backend understands four
|
||||
// keys per cluster: project_id, project_name, location_id, location_name.
|
||||
// We emit project_id+location_id when the operator picked from the
|
||||
// typeahead dropdown; we emit project_name+location_name when they
|
||||
// typed a free-form value (no id selected) that differs from the
|
||||
// parser's original suggestion.
|
||||
const overrides = {};
|
||||
for (const cid of clusterIds) {
|
||||
const inputs = document.querySelectorAll(`input[data-cluster-id="${cid}"]`);
|
||||
if (inputs.length === 0) continue;
|
||||
const o = {};
|
||||
let projectText = null, projectId = null;
|
||||
let locationText = null, locationId = null;
|
||||
// Old-style flat fields (kept for blank-meta cluster inputs which
|
||||
// use data-field="project_name" / "location_name").
|
||||
let projectNameRaw = null, locationNameRaw = null;
|
||||
inputs.forEach(i => {
|
||||
const v = (i.value || '').trim();
|
||||
const f = i.dataset.field;
|
||||
if (f === 'project') projectText = v;
|
||||
else if (f === 'project_id') projectId = v;
|
||||
else if (f === 'location') locationText = v;
|
||||
else if (f === 'location_id') locationId = v;
|
||||
else if (f === 'project_name') projectNameRaw = v;
|
||||
else if (f === 'location_name') locationNameRaw = v;
|
||||
});
|
||||
|
||||
if (projectId) {
|
||||
o.project_id = projectId;
|
||||
} else if (projectText) {
|
||||
o.project_name = projectText;
|
||||
} else if (projectNameRaw) {
|
||||
o.project_name = projectNameRaw;
|
||||
}
|
||||
|
||||
if (locationId) {
|
||||
o.location_id = locationId;
|
||||
} else if (locationText) {
|
||||
o.location_name = locationText;
|
||||
} else if (locationNameRaw) {
|
||||
o.location_name = locationNameRaw;
|
||||
}
|
||||
|
||||
if (Object.keys(o).length > 0) overrides[cid] = o;
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
function _showToast(message, sub, kind) {
|
||||
const toast = document.getElementById('apply-toast');
|
||||
const icon = document.getElementById('toast-icon');
|
||||
document.getElementById('toast-message').textContent = message;
|
||||
document.getElementById('toast-sub').textContent = sub || '';
|
||||
if (kind === 'success') {
|
||||
icon.innerHTML = '<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>';
|
||||
} else if (kind === 'error') {
|
||||
icon.innerHTML = '<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>';
|
||||
} else {
|
||||
icon.innerHTML = '<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange"></div>';
|
||||
}
|
||||
toast.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function _hideToast(after) {
|
||||
setTimeout(() => document.getElementById('apply-toast').classList.add('hidden'), after || 3000);
|
||||
}
|
||||
|
||||
async function _apply(clusterIds) {
|
||||
if (clusterIds.length === 0) return;
|
||||
_showToast(`Applying ${clusterIds.length} cluster${clusterIds.length === 1 ? '' : 's'}…`);
|
||||
try {
|
||||
const r = await fetch('/api/admin/metadata_backfill/apply', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
cluster_ids: clusterIds,
|
||||
overrides: _gatherOverrides(clusterIds),
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
const failedCount = (d.failed || []).length;
|
||||
|
||||
// Three states:
|
||||
// total success — applied N, no failures → green toast, 4s
|
||||
// partial — applied N, M failures → red toast + modal listing reasons
|
||||
// total failure — applied 0, failures → red toast + modal
|
||||
if (failedCount === 0) {
|
||||
const sub = `${d.applied} applied · ${d.project_ids_created.length} new project(s) · ${d.location_ids_created.length} new location(s)`;
|
||||
_showToast(`${d.applied} cluster${d.applied === 1 ? '' : 's'} applied`, sub, 'success');
|
||||
_hideToast(4000);
|
||||
} else {
|
||||
const title = d.applied > 0
|
||||
? `${d.applied} applied, ${failedCount} failed`
|
||||
: `Apply failed — ${failedCount} cluster${failedCount === 1 ? '' : 's'} could not be applied`;
|
||||
_showToast(title, 'See the details panel.', 'error');
|
||||
_hideToast(6000);
|
||||
_showFailureDetails(d.failed);
|
||||
}
|
||||
await runScan(true); // refresh
|
||||
} catch (e) {
|
||||
_showToast('Apply failed', e.message, 'error');
|
||||
_hideToast(5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Modal-ish panel that lists each failed cluster with its server-side
|
||||
// reason. Common failure modes seen in dev: missing DB tables after a
|
||||
// stale schema, blocking conflicts that slipped past the front-end guard,
|
||||
// rapidfuzz/SQLAlchemy edge cases. Operator can dismiss and either
|
||||
// retry the cluster, skip it, or fix the underlying issue.
|
||||
function _showFailureDetails(failed) {
|
||||
let panel = document.getElementById('apply-failure-panel');
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'apply-failure-panel';
|
||||
panel.className = 'fixed bottom-6 left-6 right-6 sm:right-auto sm:max-w-xl bg-white dark:bg-slate-800 rounded-xl shadow-2xl border border-red-200 dark:border-red-800 p-4 z-40';
|
||||
document.body.appendChild(panel);
|
||||
}
|
||||
const rows = failed.map(f => `
|
||||
<li class="flex items-start gap-2 text-sm border-l-2 border-red-300 dark:border-red-700 pl-3 py-1">
|
||||
<code class="font-mono text-xs text-gray-500 dark:text-gray-400">${(f.cluster_id || '').slice(0, 8)}…</code>
|
||||
<span class="text-gray-800 dark:text-gray-200 flex-1">${_esc(f.reason || '(no reason)')}</span>
|
||||
</li>
|
||||
`).join('');
|
||||
panel.innerHTML = `
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white">
|
||||
<svg class="w-5 h-5 inline text-red-500 -mt-0.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
${failed.length} cluster${failed.length === 1 ? '' : 's'} failed to apply
|
||||
</h4>
|
||||
<button onclick="document.getElementById('apply-failure-panel').remove()"
|
||||
class="text-gray-400 hover:text-gray-600 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>
|
||||
<ul class="space-y-1 max-h-64 overflow-y-auto">${rows}</ul>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
Common causes: missing DB schema (run the migration sweep), blocking conflict
|
||||
with an existing UnitAssignment, or a UNIQUE constraint collision on the
|
||||
project name. Re-scan and the failed clusters reappear as pending — fix the
|
||||
underlying issue and retry.
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
async function applyOne(clusterId) { return _apply([clusterId]); }
|
||||
|
||||
async function applyBulkHighConfidence() {
|
||||
const high = _scanData.by_confidence.high.filter(s => !s.blocking_conflict);
|
||||
const ids = high.map(s => s.cluster_id);
|
||||
if (ids.length === 0) return;
|
||||
if (!confirm(`Apply ${ids.length} high-confidence cluster${ids.length === 1 ? '' : 's'}? This will create projects, locations, and assignments without further prompting.`)) return;
|
||||
return _apply(ids);
|
||||
}
|
||||
|
||||
async function skipOne(clusterId) {
|
||||
if (!confirm('Skip this cluster? It will not reappear in future scans.')) return;
|
||||
_showToast('Skipping…');
|
||||
try {
|
||||
const r = await fetch('/api/admin/metadata_backfill/skip', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ cluster_ids: [clusterId] }),
|
||||
});
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
_showToast('Skipped', '', 'success');
|
||||
_hideToast(2000);
|
||||
await runScan(true);
|
||||
} catch (e) {
|
||||
_showToast('Skip failed', e.message, 'error');
|
||||
_hideToast(4000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,267 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Project Tidy - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-6">
|
||||
<nav class="flex items-center space-x-2 text-sm">
|
||||
<a href="/settings" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
<svg class="w-4 h-4 text-gray-400" 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"></path>
|
||||
</svg>
|
||||
<span class="text-gray-900 dark:text-white font-medium">Project Tidy</span>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Project Tidy</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Find duplicate-looking projects via fuzzy name matching, then merge them with one click.
|
||||
Useful after the metadata-backfill parser creates near-duplicates from operator name variations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-5">
|
||||
<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">Similarity threshold</label>
|
||||
<select id="threshold" onchange="runScan()"
|
||||
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="0.95">≥ 95% — near-identical only (typos)</option>
|
||||
<option value="0.90">≥ 90% — close variants</option>
|
||||
<option value="0.85" selected>≥ 85% — fuzzy match floor (recommended)</option>
|
||||
<option value="0.80">≥ 80% — aggressive (more false positives)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="runScan()"
|
||||
class="ml-auto px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
↻ Scan for duplicates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="results" class="space-y-3">
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
Click "Scan for duplicates" to find pairs.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply progress toast -->
|
||||
<div id="tidy-toast" class="hidden fixed bottom-6 right-6 bg-white dark:bg-slate-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 p-4 z-50 max-w-md">
|
||||
<div class="flex items-center gap-3">
|
||||
<div id="toast-icon" class="shrink-0">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange"></div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p id="toast-message" class="text-sm font-medium text-gray-900 dark:text-white">Working…</p>
|
||||
<p id="toast-sub" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _pairs = [];
|
||||
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _sourceBadge(source) {
|
||||
if (source === 'metadata_backfill' || source === 'metadata_backfill_swap') {
|
||||
return '<span class="px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300" title="Auto-created by the metadata-backfill parser">parser</span>';
|
||||
}
|
||||
return '<span class="px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300" title="Manually created via the UI">manual</span>';
|
||||
}
|
||||
|
||||
function _showToast(message, sub, kind) {
|
||||
const toast = document.getElementById('tidy-toast');
|
||||
const icon = document.getElementById('toast-icon');
|
||||
document.getElementById('toast-message').textContent = message;
|
||||
document.getElementById('toast-sub').textContent = sub || '';
|
||||
if (kind === 'success') {
|
||||
icon.innerHTML = '<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>';
|
||||
} else if (kind === 'error') {
|
||||
icon.innerHTML = '<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>';
|
||||
} else {
|
||||
icon.innerHTML = '<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange"></div>';
|
||||
}
|
||||
toast.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function _hideToast(after) {
|
||||
setTimeout(() => document.getElementById('tidy-toast').classList.add('hidden'), after || 3000);
|
||||
}
|
||||
|
||||
async function runScan() {
|
||||
const results = document.getElementById('results');
|
||||
results.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Scanning…</div>';
|
||||
const threshold = document.getElementById('threshold').value;
|
||||
try {
|
||||
const r = await fetch(`/api/projects/admin/duplicate_pairs?threshold=${threshold}`);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
_pairs = d.pairs || [];
|
||||
render();
|
||||
} catch (e) {
|
||||
results.innerHTML = `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 text-center text-red-500">Scan failed: ${_esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const results = document.getElementById('results');
|
||||
if (_pairs.length === 0) {
|
||||
results.innerHTML = `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">✨ No duplicates above the threshold</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400">Lower the threshold or call it good.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-3">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Found <strong>${_pairs.length}</strong> duplicate pair${_pairs.length === 1 ? '' : 's'}.
|
||||
Review the suggested merge direction (arrow points at the target project to keep),
|
||||
adjust if needed, then click <strong>Merge</strong>.
|
||||
</div>
|
||||
</div>`;
|
||||
results.innerHTML = summary + _pairs.map(_renderPair).join('');
|
||||
}
|
||||
|
||||
function _renderPair(pair, i) {
|
||||
const sourceTarget = pair.suggested_target_id === pair.a.id ? 'a' : 'b';
|
||||
return `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4" data-idx="${i}">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">${(pair.score * 100).toFixed(0)}% match</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(pair.reason)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="confirmMerge(${i}, 'a_into_b')"
|
||||
class="px-3 py-1.5 text-xs rounded ${sourceTarget === 'b' ? 'bg-seismo-orange hover:bg-seismo-navy text-white font-medium' : 'border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}">
|
||||
Merge A → B
|
||||
</button>
|
||||
<button onclick="confirmMerge(${i}, 'b_into_a')"
|
||||
class="px-3 py-1.5 text-xs rounded ${sourceTarget === 'a' ? 'bg-seismo-orange hover:bg-seismo-navy text-white font-medium' : 'border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}">
|
||||
Merge B → A
|
||||
</button>
|
||||
<button onclick="dismissPair(${i})"
|
||||
title="Hide this pair (not actually a duplicate)"
|
||||
class="px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 rounded hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Not a dup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
${_renderProject(pair.a, 'A', sourceTarget === 'a')}
|
||||
${_renderProject(pair.b, 'B', sourceTarget === 'b')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderProject(p, label, isTarget) {
|
||||
const borderCls = isTarget ? 'border-seismo-orange ring-1 ring-seismo-orange/30' : 'border-gray-200 dark:border-gray-700';
|
||||
return `<a href="/projects/${_esc(p.id)}" target="_blank"
|
||||
class="block bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 border ${borderCls} hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between gap-2 mb-1">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Project ${label}${isTarget ? ' · suggested target' : ''}</div>
|
||||
${_sourceBadge(p.source)}
|
||||
</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white text-sm">${_esc(p.name)}</div>
|
||||
${p.project_number ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">#${_esc(p.project_number)}</div>` : ''}
|
||||
${p.client_name ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${_esc(p.client_name)}</div>` : ''}
|
||||
<div class="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400 mt-2">
|
||||
<span><strong>${p.location_count}</strong> location${p.location_count === 1 ? '' : 's'}</span>
|
||||
<span><strong>${p.assignment_count}</strong> assignment${p.assignment_count === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
async function confirmMerge(idx, direction) {
|
||||
const pair = _pairs[idx];
|
||||
if (!pair) return;
|
||||
let sourceId, targetId, sourceName, targetName;
|
||||
if (direction === 'a_into_b') {
|
||||
sourceId = pair.a.id; targetId = pair.b.id;
|
||||
sourceName = pair.a.name; targetName = pair.b.name;
|
||||
} else {
|
||||
sourceId = pair.b.id; targetId = pair.a.id;
|
||||
sourceName = pair.b.name; targetName = pair.a.name;
|
||||
}
|
||||
|
||||
// Pull preview to surface conflicts / consolidation count BEFORE merging.
|
||||
let preview;
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${sourceId}/merge_preview?target_id=${targetId}`);
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(err.detail || ('HTTP ' + r.status));
|
||||
}
|
||||
preview = await r.json();
|
||||
} catch (e) {
|
||||
alert('Preview failed: ' + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = [
|
||||
`${preview.total_assignments_moving} assignment(s)`,
|
||||
`${preview.total_sessions_moving} session(s)`,
|
||||
`${preview.total_data_files_moving} data file(s)`,
|
||||
].join(', ');
|
||||
let consolidation = '';
|
||||
const consolidates = preview.location_plans.filter(p => p.action === 'consolidate').length;
|
||||
if (consolidates > 0) {
|
||||
consolidation = `\n\n${consolidates} same-named location(s) will be consolidated.`;
|
||||
}
|
||||
const ok = confirm(
|
||||
`Merge "${sourceName}" into "${targetName}"?\n\n` +
|
||||
`Will move: ${summary}.${consolidation}\n\n` +
|
||||
`Source will be soft-deleted. This is reversible only via direct DB edit.`
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
_showToast(`Merging "${sourceName}" → "${targetName}"…`);
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${sourceId}/merge_into?target_id=${targetId}`, { method: 'POST' });
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(err.detail || ('HTTP ' + r.status));
|
||||
}
|
||||
const d = await r.json();
|
||||
_showToast(`Merged into "${targetName}"`,
|
||||
`${d.assignments_moved} assignment(s), ${d.locations_moved + d.locations_consolidated} location(s)`,
|
||||
'success');
|
||||
_hideToast(3500);
|
||||
// Re-scan: list updates without the merged pair.
|
||||
await runScan();
|
||||
} catch (e) {
|
||||
_showToast('Merge failed', e.message, 'error');
|
||||
_hideToast(5000);
|
||||
}
|
||||
}
|
||||
|
||||
function dismissPair(idx) {
|
||||
// Just hide locally for now; doesn't persist across re-scans.
|
||||
// A persistent "ignore pair" feature would need a new table; defer.
|
||||
_pairs.splice(idx, 1);
|
||||
render();
|
||||
}
|
||||
|
||||
// Auto-scan on load with default threshold.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
runScan();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
+44
-33
@@ -85,7 +85,7 @@
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar (Responsive) -->
|
||||
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
||||
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
||||
<!-- Logo -->
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<a href="/" class="block">
|
||||
@@ -109,41 +109,24 @@
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<a href="/roster" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/roster' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
{# Devices — single sidebar entry covering all device-type
|
||||
pages. Lands on /roster (the unified all-devices view);
|
||||
the tab strip on each underlying page lets the operator
|
||||
drill into seismograph / SLM / modem specifics.
|
||||
Active when on any /seismographs, /sound-level-meters,
|
||||
/modems, /roster, /pair-devices, /unit/* page. #}
|
||||
{% set _is_devices = (
|
||||
request.url.path in ('/seismographs', '/sound-level-meters', '/modems', '/roster', '/pair-devices')
|
||||
or request.url.path.startswith('/unit/')
|
||||
or request.url.path.startswith('/slm/')
|
||||
) %}
|
||||
<a href="/roster" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if _is_devices %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" 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>
|
||||
</svg>
|
||||
Devices
|
||||
</a>
|
||||
|
||||
<a href="/seismographs" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/seismographs' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
Seismographs
|
||||
</a>
|
||||
|
||||
<a href="/sound-level-meters" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sound-level-meters' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||
</svg>
|
||||
Sound Level Meters
|
||||
</a>
|
||||
|
||||
<a href="/modems" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/modems' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
Modems
|
||||
</a>
|
||||
|
||||
<a href="/pair-devices" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/pair-devices' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||
</svg>
|
||||
Pair Devices
|
||||
</a>
|
||||
|
||||
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
@@ -151,11 +134,39 @@
|
||||
Projects
|
||||
</a>
|
||||
|
||||
{# Events — fleet-wide event database (SFM). Cross-project
|
||||
sortable/filterable event list. Day-to-day event browsing
|
||||
for a specific location or unit lives on those detail
|
||||
pages; this is the firehose for cross-cutting queries. #}
|
||||
<a href="/sfm" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sfm' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
Events
|
||||
</a>
|
||||
|
||||
{# Tools — operator workflow hub. Active when on /tools
|
||||
itself or any of the workflow pages it links into
|
||||
(project tidy, metadata backfill, pair devices). #}
|
||||
{% set _is_tools = (
|
||||
request.url.path == '/tools'
|
||||
or request.url.path == '/pair-devices'
|
||||
or request.url.path == '/settings/developer/project-tidy'
|
||||
or request.url.path == '/settings/developer/metadata-backfill'
|
||||
) %}
|
||||
<a href="/tools" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if _is_tools %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
Tools
|
||||
</a>
|
||||
|
||||
<a href="/fleet-calendar" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/fleet-calendar') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Fleet Calendar
|
||||
Job Planner
|
||||
</a>
|
||||
|
||||
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
@@ -193,14 +204,14 @@
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="main-content flex-1 overflow-y-auto">
|
||||
<div class="p-8">
|
||||
<div class="{% if request.query_params.get('embed') == '1' %}p-4{% else %}p-8{% endif %}">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation (Mobile Only) -->
|
||||
<nav class="bottom-nav">
|
||||
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
||||
<div class="grid grid-cols-4 h-16">
|
||||
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
+215
-138
@@ -29,7 +29,55 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
|
||||
<!-- Fleet Summary Card -->
|
||||
<!-- Recent Alerts Card (col 1) -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Call-Ins Card (cols 2-3, double-wide) -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 md:col-span-2 lg:col-span-2" id="recent-callins-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 hidden sm:inline">from SFM event forwards</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" id="recent-callins-content">
|
||||
<div id="recent-callins-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
||||
</div>
|
||||
<a href="/sfm" class="block mt-3 text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
||||
View all events →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fleet Summary Card (col 4) -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-summary-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||
@@ -57,6 +105,10 @@
|
||||
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
||||
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-orange-600 dark:text-orange-400">Allocated</span>
|
||||
<span id="allocated-units" class="text-3xl md:text-2xl font-bold text-orange-500 dark:text-orange-400">--</span>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
@@ -117,74 +169,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Alerts Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recently Called In Units Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-callins-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" id="recent-callins-content">
|
||||
<div id="recent-callins-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
||||
</div>
|
||||
<button id="show-all-callins" class="hidden mt-3 w-full text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
||||
Show all recent call-ins
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Scheduled Actions Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="todays-actions-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('todays-actions')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="todays-actions-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" id="todays-actions-content"
|
||||
hx-get="/dashboard/todays-actions"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Filters -->
|
||||
@@ -265,6 +249,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Schedule — horizontal collapsible card.
|
||||
Default collapsed; auto-expands when an upcoming action is detected
|
||||
(pending + scheduled within the next 4h). JS reads
|
||||
data-has-upcoming on the inner partial after htmx swap. -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-4 mb-8" id="todays-actions-card">
|
||||
<div class="flex items-center justify-between cursor-pointer" onclick="toggleTodaysSchedule()">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
|
||||
<span id="todays-actions-badge"
|
||||
class="hidden text-xs font-medium px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
|
||||
</span>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform collapsed" id="todays-actions-chevron"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content collapsed mt-4" id="todays-actions-content"
|
||||
hx-get="/dashboard/todays-actions"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-swap="onTodaysActionsSwap(this)">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Photos Section -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="recent-photos-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
||||
@@ -360,6 +374,17 @@
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Today's Schedule — horizontal collapsible at all breakpoints. */
|
||||
#todays-actions-content.collapsed {
|
||||
display: none;
|
||||
}
|
||||
#todays-actions-chevron.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
#todays-actions-chevron {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -509,7 +534,7 @@ function renderFilteredDashboard(data) {
|
||||
// Update the Recent Alerts section with filtering
|
||||
function updateAlertsFiltered(filteredActive) {
|
||||
const alertsList = document.getElementById('alerts-list');
|
||||
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing');
|
||||
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing' && u.device_type !== 'modem');
|
||||
|
||||
if (!missingUnits.length) {
|
||||
// Check if this is because of filters or genuinely no alerts
|
||||
@@ -650,7 +675,8 @@ function toggleCard(cardName) {
|
||||
// Restore card states from localStorage on page load
|
||||
function restoreCardStates() {
|
||||
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'todays-actions', 'fleet-map', 'fleet-status'];
|
||||
// Note: todays-actions has its own collapse handling (see toggleTodaysSchedule / onTodaysActionsSwap)
|
||||
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
|
||||
|
||||
cardNames.forEach(cardName => {
|
||||
const content = document.getElementById(`${cardName}-content`);
|
||||
@@ -703,6 +729,7 @@ function updateDashboard(event) {
|
||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
||||
document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
|
||||
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||
@@ -834,89 +861,139 @@ async function loadRecentPhotos() {
|
||||
loadRecentPhotos();
|
||||
setInterval(loadRecentPhotos, 30000);
|
||||
|
||||
// Load and display recent call-ins
|
||||
let showingAllCallins = false;
|
||||
const DEFAULT_CALLINS_DISPLAY = 5;
|
||||
|
||||
// Load and display recent call-ins.
|
||||
// Source: SFM events (forwarded by series3-watcher from Blastware ACH).
|
||||
// Each event = one call-home. Heartbeat-derived endpoint /api/recent-callins
|
||||
// is being phased out but kept as a backup.
|
||||
async function loadRecentCallins() {
|
||||
const callinsList = document.getElementById('recent-callins-list');
|
||||
try {
|
||||
const response = await fetch('/api/recent-callins?hours=6');
|
||||
const response = await fetch('/api/recent-event-callins?limit=10');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load recent call-ins');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const callinsList = document.getElementById('recent-callins-list');
|
||||
const showAllButton = document.getElementById('show-all-callins');
|
||||
|
||||
if (data.call_ins && data.call_ins.length > 0) {
|
||||
// Determine how many to show
|
||||
const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length);
|
||||
const callinsToDisplay = data.call_ins.slice(0, displayCount);
|
||||
|
||||
// Build HTML for call-ins list
|
||||
let html = '';
|
||||
callinsToDisplay.forEach(callin => {
|
||||
// Status color
|
||||
const statusColor = callin.status === 'OK' ? 'green' : callin.status === 'Pending' ? 'yellow' : 'red';
|
||||
const statusClass = callin.status === 'OK' ? 'bg-green-500' : callin.status === 'Pending' ? 'bg-yellow-500' : 'bg-red-500';
|
||||
|
||||
// Build location/note line
|
||||
let subtitle = '';
|
||||
if (callin.location) {
|
||||
subtitle = callin.location;
|
||||
} else if (callin.note) {
|
||||
subtitle = callin.note;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-2 h-2 rounded-full ${statusClass}"></span>
|
||||
<div>
|
||||
<a href="/unit/${callin.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
|
||||
${callin.unit_id}
|
||||
</a>
|
||||
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400">${subtitle}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">${callin.time_ago}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
callinsList.innerHTML = html;
|
||||
|
||||
// Show/hide the "Show all" button
|
||||
if (data.call_ins.length > DEFAULT_CALLINS_DISPLAY) {
|
||||
showAllButton.classList.remove('hidden');
|
||||
showAllButton.textContent = showingAllCallins
|
||||
? `Show fewer (${DEFAULT_CALLINS_DISPLAY})`
|
||||
: `Show all (${data.call_ins.length})`;
|
||||
} else {
|
||||
showAllButton.classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No units have called in within the past 6 hours</p>';
|
||||
showAllButton.classList.add('hidden');
|
||||
if (!data.call_ins || data.call_ins.length === 0) {
|
||||
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No recent event call-ins from SFM</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Two-column dense grid on lg+, single column below.
|
||||
let html = '<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-4 gap-y-1">';
|
||||
data.call_ins.forEach(c => {
|
||||
const isFalse = c.false_trigger;
|
||||
const pvs = c.peak_vector_sum;
|
||||
const pvsStr = (pvs !== null && pvs !== undefined)
|
||||
? Number(pvs).toFixed(3) + ' in/s'
|
||||
: '—';
|
||||
|
||||
// Subtitle: prefer sensor_location, fallback to project.
|
||||
const subtitle = c.sensor_location || c.project || '';
|
||||
|
||||
// Status dot: amber for false trigger, green for real event,
|
||||
// gray if unit not in roster.
|
||||
const dotClass = !c.in_roster
|
||||
? 'bg-gray-400'
|
||||
: (isFalse ? 'bg-amber-400' : 'bg-green-500');
|
||||
|
||||
// Format event timestamp short (e.g. "05-13 05:00").
|
||||
let tsShort = '';
|
||||
if (c.event_timestamp) {
|
||||
const ts = c.event_timestamp.replace('T', ' ');
|
||||
// "2026-05-13 05:00:13" → "05-13 05:00"
|
||||
tsShort = ts.length >= 16 ? ts.slice(5, 16) : ts;
|
||||
}
|
||||
|
||||
const unitLink = c.in_roster
|
||||
? `<a href="/unit/${c.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">${c.unit_id}</a>`
|
||||
: `<span class="font-medium text-gray-500 dark:text-gray-400" title="Not in roster">${c.unit_id}</span>`;
|
||||
|
||||
html += `
|
||||
<div class="flex items-center justify-between py-1.5 border-b border-gray-100 dark:border-gray-700/50 last:border-0">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="w-2 h-2 rounded-full ${dotClass} flex-shrink-0" title="${isFalse ? 'False trigger' : 'Event'}"></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
${unitLink}
|
||||
${isFalse ? '<span class="text-[10px] uppercase tracking-wide text-amber-600 dark:text-amber-400">false</span>' : ''}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">${pvsStr}</span>
|
||||
</div>
|
||||
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="${subtitle.replace(/"/g, '"')}">${subtitle}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right ml-2 flex-shrink-0">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 block">${c.time_ago}</span>
|
||||
${tsShort ? `<span class="text-[10px] text-gray-400 dark:text-gray-500 block font-mono">${tsShort}</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
callinsList.innerHTML = html;
|
||||
} catch (error) {
|
||||
console.error('Error loading recent call-ins:', error);
|
||||
document.getElementById('recent-callins-list').innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
||||
callinsList.innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle show all/show fewer
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const showAllButton = document.getElementById('show-all-callins');
|
||||
showAllButton.addEventListener('click', function() {
|
||||
showingAllCallins = !showingAllCallins;
|
||||
loadRecentCallins();
|
||||
});
|
||||
});
|
||||
|
||||
// Load recent call-ins on page load and refresh every 30 seconds
|
||||
// Load recent call-ins on page load and refresh every 30 seconds.
|
||||
loadRecentCallins();
|
||||
setInterval(loadRecentCallins, 30000);
|
||||
|
||||
// ===== Today's Schedule horizontal card =====
|
||||
function toggleTodaysSchedule() {
|
||||
const content = document.getElementById('todays-actions-content');
|
||||
const chevron = document.getElementById('todays-actions-chevron');
|
||||
if (!content || !chevron) return;
|
||||
const isCollapsed = content.classList.toggle('collapsed');
|
||||
chevron.classList.toggle('collapsed', isCollapsed);
|
||||
// Remember the user's explicit choice so we don't fight them on the next
|
||||
// 30s htmx refresh.
|
||||
localStorage.setItem('todaysScheduleUserToggled', '1');
|
||||
localStorage.setItem('todaysScheduleCollapsed', isCollapsed ? '1' : '0');
|
||||
}
|
||||
|
||||
function onTodaysActionsSwap(el) {
|
||||
// Read pending/total counts from the rendered partial to drive
|
||||
// auto-expand + the header badge.
|
||||
const badge = document.getElementById('todays-actions-badge');
|
||||
const content = document.getElementById('todays-actions-content');
|
||||
const chevron = document.getElementById('todays-actions-chevron');
|
||||
if (!content || !chevron) return;
|
||||
|
||||
// Count yellow status indicators in the rendered partial as a proxy for
|
||||
// "pending action present today".
|
||||
const pendingDots = el.querySelectorAll('.bg-yellow-400').length;
|
||||
const pendingTimes = el.querySelectorAll('.text-yellow-600').length;
|
||||
const hasPending = pendingDots > 0 || pendingTimes > 0;
|
||||
|
||||
if (badge) {
|
||||
if (hasPending) {
|
||||
const n = Math.max(pendingDots, pendingTimes);
|
||||
badge.textContent = `${n} pending today`;
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-expand only if the user hasn't manually toggled this session AND
|
||||
// there's something pending. Once the user collapses/expands manually,
|
||||
// their preference sticks.
|
||||
const userToggled = localStorage.getItem('todaysScheduleUserToggled') === '1';
|
||||
if (!userToggled && hasPending) {
|
||||
content.classList.remove('collapsed');
|
||||
chevron.classList.remove('collapsed');
|
||||
} else if (!userToggled && !hasPending) {
|
||||
content.classList.add('collapsed');
|
||||
chevron.classList.add('collapsed');
|
||||
} else if (userToggled) {
|
||||
const stored = localStorage.getItem('todaysScheduleCollapsed') === '1';
|
||||
content.classList.toggle('collapsed', stored);
|
||||
chevron.classList.toggle('collapsed', stored);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
+1637
-44
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
{% block title %}Field Modems - Terra-View{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "partials/fleet_tab_strip.html" %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -36,7 +36,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Age -->
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-right flex-shrink-0 flex items-center gap-2">
|
||||
{% if unit.last_seen_source == 'sfm' %}
|
||||
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-seismo-orange/10 text-seismo-orange font-semibold"
|
||||
title="Status sourced from SFM event forwards (primary)">SFM</span>
|
||||
{% elif unit.last_seen_source == 'heartbeat' %}
|
||||
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-300"
|
||||
title="Status sourced from watcher heartbeat (backup)">HB</span>
|
||||
{% endif %}
|
||||
<span class="text-sm {% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400{% else %}text-gray-500 dark:text-gray-400{% endif %}">
|
||||
{{ unit.age }}
|
||||
</span>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
{% for unit in units %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
data-device-type="{{ unit.device_type }}"
|
||||
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
|
||||
data-health="{{ unit.status }}"
|
||||
data-id="{{ unit.id }}"
|
||||
data-type="{{ unit.device_type }}"
|
||||
@@ -62,6 +62,8 @@
|
||||
<div class="flex items-center space-x-2">
|
||||
{% if unit.out_for_calibration %}
|
||||
<span class="w-3 h-3 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
||||
{% elif unit.allocated %}
|
||||
<span class="w-3 h-3 rounded-full bg-orange-400" title="Allocated"></span>
|
||||
{% elif not unit.deployed %}
|
||||
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||
{% elif unit.status == 'OK' %}
|
||||
@@ -76,6 +78,8 @@
|
||||
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||
{% elif unit.out_for_calibration %}
|
||||
<span class="w-2 h-2 rounded-full bg-purple-400" title="Out for Calibration"></span>
|
||||
{% elif unit.allocated %}
|
||||
<span class="w-2 h-2 rounded-full bg-orange-400" title="Allocated"></span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||
{% endif %}
|
||||
@@ -207,7 +211,7 @@
|
||||
<div class="unit-card device-card"
|
||||
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||
data-device-type="{{ unit.device_type }}"
|
||||
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
|
||||
data-health="{{ unit.status }}"
|
||||
data-unit-id="{{ unit.id }}"
|
||||
data-age="{{ unit.age }}">
|
||||
@@ -216,6 +220,8 @@
|
||||
<div class="flex items-center gap-2">
|
||||
{% if unit.out_for_calibration %}
|
||||
<span class="w-4 h-4 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
||||
{% elif unit.allocated %}
|
||||
<span class="w-4 h-4 rounded-full bg-orange-400" title="Allocated"></span>
|
||||
{% elif not unit.deployed %}
|
||||
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||
{% elif unit.status == 'OK' %}
|
||||
@@ -231,12 +237,13 @@
|
||||
</div>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||
{% if unit.out_for_calibration %}bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300
|
||||
{% elif unit.allocated %}bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300
|
||||
{% elif unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||
{% endif %}">
|
||||
{% if unit.out_for_calibration %}Out for Cal{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||
{% if unit.out_for_calibration %}Out for Cal{% elif unit.allocated %}Allocated{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{# Shared event detail modal.
|
||||
|
||||
Include this partial on any page that wants to call showEventDetail(eventId)
|
||||
from event-modal.js. The partial provides only the modal shell — the
|
||||
actual content is rendered by JS into #event-detail-modal-content.
|
||||
|
||||
Usage:
|
||||
{% include 'partials/event_detail_modal.html' %}
|
||||
<script src="/static/event-modal.js"></script>
|
||||
#}
|
||||
<div id="event-detail-modal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="absolute inset-0 bg-black/60" onclick="closeEventDetailModal()"></div>
|
||||
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-3xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[88vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="event-detail-modal-title">Event Detail</h3>
|
||||
<button onclick="closeEventDetailModal()"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||
<svg class="w-6 h-6" 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"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="event-detail-modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,100 +1,203 @@
|
||||
<!-- Reservations List -->
|
||||
{% if reservations %}
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-2">
|
||||
{% for item in reservations %}
|
||||
{% set res = item.reservation %}
|
||||
<div class="flex items-center justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
{% set card_id = "res-card-" ~ res.id %}
|
||||
{% set detail_id = "res-detail-" ~ res.id %}
|
||||
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
style="border-left: 4px solid {{ res.color }};">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3>
|
||||
{% if item.has_conflicts %}
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-full"
|
||||
title="{{ item.conflict_count }} unit(s) have calibration expiring during this job">
|
||||
{{ item.conflict_count }} conflict{{ 's' if item.conflict_count != 1 else '' }}
|
||||
</span>
|
||||
|
||||
<!-- Header row (always visible, clickable) -->
|
||||
<div class="res-card-header flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors select-none"
|
||||
data-res-id="{{ res.id }}"
|
||||
onclick="toggleResCard('{{ res.id }}')">
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3>
|
||||
{% if res.device_type == 'slm' %}
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded">SLM</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded">Seismograph</span>
|
||||
{% endif %}
|
||||
{% if item.has_conflicts %}
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded"
|
||||
title="{{ item.conflict_count }} unit(s) will need a calibration swap during this job">
|
||||
{{ item.conflict_count }} cal swap{{ 's' if item.conflict_count != 1 else '' }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{{ res.start_date.strftime('%b %d, %Y') }} –
|
||||
{% if res.end_date %}
|
||||
{{ res.end_date.strftime('%b %d, %Y') }}
|
||||
{% elif res.end_date_tbd %}
|
||||
<span class="text-yellow-600 dark:text-yellow-400 font-medium">TBD</span>
|
||||
{% if res.estimated_end_date %}
|
||||
<span class="text-gray-400">(est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Counts -->
|
||||
<div class="flex flex-col items-end gap-1 mx-4 flex-shrink-0">
|
||||
{% set full = item.assigned_count == item.location_count and item.location_count > 0 %}
|
||||
{% set remaining = item.location_count - item.assigned_count %}
|
||||
<!-- Number row -->
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">est. {% if res.estimated_units %}{{ res.estimated_units }}{% else %}—{% endif %}</span>
|
||||
<span class="text-gray-300 dark:text-gray-600">·</span>
|
||||
<span class="text-base font-bold {% if full %}text-green-600 dark:text-green-400{% elif item.assigned_count == 0 %}text-gray-400 dark:text-gray-500{% else %}text-amber-500 dark:text-amber-400{% endif %}">
|
||||
{{ item.assigned_count }}/{{ item.location_count }}
|
||||
</span>
|
||||
{% if remaining > 0 %}
|
||||
<span class="text-xs text-amber-500 dark:text-amber-400 whitespace-nowrap">({{ remaining }} more)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Progress squares -->
|
||||
{% if item.location_count > 0 %}
|
||||
<div class="flex gap-0.5">
|
||||
{% for i in range(item.location_count) %}
|
||||
<span class="w-3 h-3 rounded-sm {% if i < item.assigned_count %}{% if full %}bg-green-500{% else %}bg-amber-500{% endif %}{% else %}bg-gray-300 dark:bg-gray-600{% endif %}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ res.start_date.strftime('%b %d, %Y') }} -
|
||||
{% if res.end_date %}
|
||||
{{ res.end_date.strftime('%b %d, %Y') }}
|
||||
{% elif res.end_date_tbd %}
|
||||
<span class="text-yellow-600 dark:text-yellow-400 font-medium">TBD</span>
|
||||
{% if res.estimated_end_date %}
|
||||
<span class="text-gray-400">(est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<!-- Assign units (always visible) -->
|
||||
<button onclick="event.stopPropagation(); openPlanner('{{ res.id }}')"
|
||||
class="p-2 text-gray-400 hover:text-green-600 dark:hover:text-green-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Assign units">
|
||||
<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="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 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- "..." overflow menu -->
|
||||
<div class="relative" onclick="event.stopPropagation()">
|
||||
<button onclick="toggleResMenu('{{ res.id }}')"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="More options">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="res-menu-{{ res.id }}"
|
||||
class="hidden absolute right-0 top-8 z-20 w-44 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
|
||||
<button onclick="openPromoteModal('{{ res.id }}', '{{ res.name }}'); toggleResMenu('{{ res.id }}')"
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
|
||||
</svg>
|
||||
Promote to Project
|
||||
</button>
|
||||
<button onclick="editReservation('{{ res.id }}'); toggleResMenu('{{ res.id }}')"
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
|
||||
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}'); toggleResMenu('{{ res.id }}')"
|
||||
class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<svg id="chevron-{{ res.id }}" class="w-4 h-4 text-gray-400 transition-transform duration-200 ml-1 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expandable detail panel -->
|
||||
<div id="{{ detail_id }}" class="hidden border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-slate-800/60 px-4 py-3">
|
||||
|
||||
{% if res.notes %}
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">{{ res.notes }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3 italic">{{ res.notes }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-1 text-sm mb-3">
|
||||
<div class="text-gray-500 dark:text-gray-400">Estimated</div>
|
||||
<div class="font-medium {% if res.estimated_units %}text-gray-800 dark:text-gray-200{% else %}text-gray-400 dark:text-gray-500 italic{% endif %}">
|
||||
{% if res.estimated_units %}{{ res.estimated_units }} unit{{ 's' if res.estimated_units != 1 else '' }}{% else %}not specified{% endif %}
|
||||
</div>
|
||||
<div class="text-gray-500 dark:text-gray-400">Locations</div>
|
||||
<div class="font-medium text-gray-800 dark:text-gray-200">{{ item.assigned_count }} of {{ item.location_count }} filled</div>
|
||||
{% if item.assigned_count < item.location_count %}
|
||||
<div class="text-gray-500 dark:text-gray-400">Still needed</div>
|
||||
<div class="font-medium text-amber-600 dark:text-amber-400">{{ item.location_count - item.assigned_count }} location{{ 's' if (item.location_count - item.assigned_count) != 1 else '' }} remaining</div>
|
||||
{% endif %}
|
||||
{% if item.has_conflicts %}
|
||||
<div class="text-gray-500 dark:text-gray-400">Cal swaps</div>
|
||||
<div class="font-medium text-amber-600 dark:text-amber-400">{{ item.conflict_count }} unit{{ 's' if item.conflict_count != 1 else '' }} will need swapping during job</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.assigned_units %}
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">Monitoring Locations</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
{% for u in item.assigned_units %}
|
||||
<div class="rounded bg-white dark:bg-slate-700 border border-gray-100 dark:border-gray-600 text-sm">
|
||||
<div class="flex items-center gap-3 px-3 py-1.5">
|
||||
<span class="text-gray-400 dark:text-gray-500 text-xs w-12 flex-shrink-0">Loc. {{ loop.index }}</span>
|
||||
<div class="flex flex-col min-w-0">
|
||||
{% if u.location_name %}
|
||||
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300 truncate">{{ u.location_name }}</span>
|
||||
{% endif %}
|
||||
<button onclick="openUnitDetailModal('{{ u.id }}')"
|
||||
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left text-sm">{{ u.id }}</button>
|
||||
</div>
|
||||
<span class="flex-1"></span>
|
||||
{% if u.power_type == 'ac' %}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded">A/C</span>
|
||||
{% elif u.power_type == 'solar' %}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 rounded">Solar</span>
|
||||
{% endif %}
|
||||
{% if u.deployed %}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded">Deployed</span>
|
||||
{% else %}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 rounded">Benched</span>
|
||||
{% endif %}
|
||||
{% if u.last_calibrated %}
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">Cal: {{ u.last_calibrated.strftime('%b %d, %Y') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if u.notes %}
|
||||
<p class="px-3 pb-1.5 text-xs text-gray-400 dark:text-gray-500 italic">{{ u.notes }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 italic">No units assigned yet. Click the clipboard icon to plan.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-right ml-4">
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{% if res.assignment_type == 'quantity' %}
|
||||
{{ item.assigned_count }}/{{ res.quantity_needed or '?' }}
|
||||
{% else %}
|
||||
{{ item.assigned_count }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ 'units needed' if res.assignment_type == 'quantity' else 'units assigned' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4 flex items-center gap-2">
|
||||
<button onclick="editReservation('{{ res.id }}')"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Edit reservation">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}')"
|
||||
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Delete reservation">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function deleteReservation(id, name) {
|
||||
if (!confirm(`Delete reservation "${name}"?\n\nThis will remove all unit assignments.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/fleet-calendar/reservations/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert('Error: ' + (data.detail || 'Failed to delete'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error deleting reservation');
|
||||
}
|
||||
}
|
||||
|
||||
// editReservation is defined in fleet_calendar.html
|
||||
</script>
|
||||
<!-- toggleResCard, deleteReservation, editReservation, openUnitDetailModal defined in fleet_calendar.html -->
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400">No reservations for {{ year }}</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Reservation" to plan unit assignments</p>
|
||||
<p class="text-gray-500 dark:text-gray-400">No jobs yet</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Job" to start planning a deployment</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
{# Fleet tab strip.
|
||||
|
||||
Shared header for every page under the "Fleet" sidebar section. Each
|
||||
underlying page (/roster, /seismographs, /sound-level-meters, /modems)
|
||||
keeps its own custom layout — this partial just provides the tab
|
||||
navigation across the top so they feel like one logical area.
|
||||
|
||||
The active tab is detected from request.url.path so deep links work.
|
||||
|
||||
Usage at top of any Fleet-section template:
|
||||
{% include 'partials/fleet_tab_strip.html' %}
|
||||
#}
|
||||
{% set _path = request.url.path %}
|
||||
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-end justify-between flex-wrap gap-3 mb-0">
|
||||
<nav class="flex gap-1">
|
||||
<a href="/roster"
|
||||
class="px-4 py-2 -mb-px border-b-2 text-sm font-medium transition-colors {% if _path == '/roster' %}border-seismo-orange text-seismo-orange{% else %}border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600{% endif %}">
|
||||
<svg class="w-4 h-4 inline -mt-0.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
All Devices
|
||||
</a>
|
||||
<a href="/seismographs"
|
||||
class="px-4 py-2 -mb-px border-b-2 text-sm font-medium transition-colors {% if _path == '/seismographs' %}border-seismo-orange text-seismo-orange{% else %}border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600{% endif %}">
|
||||
<svg class="w-4 h-4 inline -mt-0.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||
</svg>
|
||||
Seismographs
|
||||
</a>
|
||||
<a href="/sound-level-meters"
|
||||
class="px-4 py-2 -mb-px border-b-2 text-sm font-medium transition-colors {% if _path == '/sound-level-meters' %}border-seismo-orange text-seismo-orange{% else %}border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600{% endif %}">
|
||||
<svg class="w-4 h-4 inline -mt-0.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072"/>
|
||||
</svg>
|
||||
Sound Level Meters
|
||||
</a>
|
||||
<a href="/modems"
|
||||
class="px-4 py-2 -mb-px border-b-2 text-sm font-medium transition-colors {% if _path == '/modems' %}border-seismo-orange text-seismo-orange{% else %}border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600{% endif %}">
|
||||
<svg class="w-4 h-4 inline -mt-0.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"/>
|
||||
</svg>
|
||||
Modems
|
||||
</a>
|
||||
</nav>
|
||||
<a href="/pair-devices"
|
||||
class="mb-1 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm 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 transition-colors">
|
||||
<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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||||
</svg>
|
||||
Pair Devices
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,16 +63,25 @@ Include this modal in pages that use the project picker.
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Project Type <span class="text-red-500">*</span>
|
||||
Modules
|
||||
<span class="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<select name="project_type_id"
|
||||
id="qcp-project-type"
|
||||
required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<option value="vibration_monitoring">Vibration Monitoring</option>
|
||||
<option value="sound_monitoring">Sound Monitoring</option>
|
||||
<option value="combined">Combined (Vibration + Sound)</option>
|
||||
</select>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-2 p-2.5 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-orange-400 has-[:checked]:border-orange-400 has-[:checked]:bg-orange-50 dark:has-[:checked]:bg-orange-900/20 transition-colors">
|
||||
<input type="checkbox" name="module_sound" value="1" class="accent-seismo-orange">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white leading-tight">Sound</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">SLMs, sessions, reports</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 p-2.5 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-blue-400 has-[:checked]:border-blue-400 has-[:checked]:bg-blue-50 dark:has-[:checked]:bg-blue-900/20 transition-colors">
|
||||
<input type="checkbox" name="module_vibration" value="1" class="accent-blue-500">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white leading-tight">Vibration</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Seismographs, modems</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -222,6 +231,20 @@ if (typeof openCreateProjectModal === 'undefined') {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
const projectId = result.project_id;
|
||||
|
||||
// Add selected modules
|
||||
const moduleMap = { module_sound: 'sound_monitoring', module_vibration: 'vibration_monitoring' };
|
||||
for (const [field, moduleType] of Object.entries(moduleMap)) {
|
||||
if (formData.get(field)) {
|
||||
await fetch(`/api/projects/${projectId}/modules`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ module_type: moduleType }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build display text from form values
|
||||
const parts = [];
|
||||
const projectNumber = formData.get('project_number');
|
||||
@@ -235,7 +258,7 @@ if (typeof openCreateProjectModal === 'undefined') {
|
||||
const displayText = parts.join(' - ');
|
||||
|
||||
// Select the newly created project in the picker
|
||||
selectProject(result.project_id, displayText, pickerId);
|
||||
selectProject(projectId, displayText, pickerId);
|
||||
|
||||
// Close modal
|
||||
closeCreateProjectModal();
|
||||
|
||||
@@ -10,11 +10,6 @@
|
||||
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
||||
{{ item.location.name }}
|
||||
</a>
|
||||
{% if item.location.location_type %}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
{{ item.location.location_type|capitalize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.location.description %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if project.status == 'active' %}
|
||||
{% if project.status == 'upcoming' %}
|
||||
<span class="px-3 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
|
||||
{% elif project.status == 'active' %}
|
||||
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||
{% elif project.status == 'on_hold' %}
|
||||
<span class="px-3 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||||
@@ -50,14 +52,14 @@
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
||||
{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}
|
||||
NRLs
|
||||
{% else %}
|
||||
Locations
|
||||
{% endif %}
|
||||
</h3>
|
||||
<button onclick="openLocationModal('{% if project_type and project_type.id == 'sound_monitoring' %}sound{% elif project_type and project_type.id == 'vibration_monitoring' %}vibration{% else %}{% endif %}')" class="text-sm text-seismo-orange hover:text-seismo-navy">
|
||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
||||
<button onclick="openLocationModal('{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}sound{% elif 'vibration_monitoring' in modules and 'sound_monitoring' not in modules %}vibration{% endif %}')" class="text-sm text-seismo-orange hover:text-seismo-navy">
|
||||
{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}
|
||||
Add NRL
|
||||
{% else %}
|
||||
Add Location
|
||||
@@ -65,7 +67,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div id="project-locations"
|
||||
hx-get="/api/projects/{{ project.id }}/locations{% if project_type and project_type.id == 'sound_monitoring' %}?location_type=sound{% endif %}"
|
||||
hx-get="/api/projects/{{ project.id }}/locations{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}?location_type=sound{% endif %}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="animate-pulse space-y-3">
|
||||
|
||||
@@ -3,15 +3,50 @@
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
||||
{% if project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||
{{ project.status|title }}
|
||||
</span>
|
||||
{% if project_type %}
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
||||
{% endif %}
|
||||
<div class="relative inline-block">
|
||||
<select onchange="quickUpdateStatus(this.value)"
|
||||
class="appearance-none cursor-pointer inline-flex items-center pl-3 pr-7 py-1 rounded-full text-sm font-medium border-0 focus:ring-2 focus:ring-offset-1 focus:ring-blue-500
|
||||
{% if project.status == 'upcoming' %}bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200
|
||||
{% elif project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||
{% elif project.status == 'on_hold' %}bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200
|
||||
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||
<option value="upcoming" {% if project.status == 'upcoming' %}selected{% endif %}>Upcoming</option>
|
||||
<option value="active" {% if project.status == 'active' %}selected{% endif %}>Active</option>
|
||||
<option value="on_hold" {% if project.status == 'on_hold' %}selected{% endif %}>On Hold</option>
|
||||
<option value="completed" {% if project.status == 'completed' %}selected{% endif %}>Completed</option>
|
||||
<option value="archived" {% if project.status == 'archived' %}selected{% endif %}>Archived</option>
|
||||
</select>
|
||||
<span class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-current opacity-60">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Module badges -->
|
||||
<div id="module-badges" class="flex items-center gap-1.5 flex-wrap">
|
||||
{% for m in modules %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium
|
||||
{% if m == 'sound_monitoring' %}bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300
|
||||
{% elif m == 'vibration_monitoring' %}bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300
|
||||
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||
{% if m == 'sound_monitoring' %}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072"/></svg>
|
||||
Sound Monitoring
|
||||
{% elif m == 'vibration_monitoring' %}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
Vibration Monitoring
|
||||
{% else %}{{ m }}{% endif %}
|
||||
<button onclick="removeModule('{{ m }}')" class="ml-0.5 hover:text-red-500 transition-colors" title="Remove module">
|
||||
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
<button onclick="openAddModuleModal()" class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border border-dashed border-gray-400 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:border-orange-400 hover:text-orange-500 transition-colors">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
Add Module
|
||||
</button>
|
||||
</div>
|
||||
{% if project.data_collection_mode == 'remote' %}
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -31,7 +66,7 @@
|
||||
</div>
|
||||
<!-- Project Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
||||
{% if 'sound_monitoring' in modules %}
|
||||
<a href="/api/projects/{{ project.id }}/combined-report-wizard"
|
||||
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -40,6 +75,328 @@
|
||||
Generate Combined Report
|
||||
</a>
|
||||
{% endif %}
|
||||
<button onclick="openMergeModal()"
|
||||
title="Merge this project into another (consolidates duplicates)"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 text-sm">
|
||||
<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="M17 8l4 4m0 0l-4 4m4-4H3"></path>
|
||||
</svg>
|
||||
Merge into…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merge Modal -->
|
||||
<div id="merge-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Merge "{{ project.name }}" into another project</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Source project gets soft-deleted. All its locations, assignments, sessions, and files move to the target.
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="closeMergeModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 shrink-0 ml-3">
|
||||
<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>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-4 overflow-y-auto flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Target project
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
id="merge-target-input"
|
||||
placeholder="Type to search for the project to merge INTO…"
|
||||
autocomplete="off"
|
||||
oninput="onMergeTargetInput()"
|
||||
onfocus="onMergeTargetInput()"
|
||||
onblur="setTimeout(() => document.getElementById('merge-target-dropdown').classList.add('hidden'), 150)"
|
||||
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">
|
||||
<input type="hidden" id="merge-target-id" value="">
|
||||
<div id="merge-target-dropdown"
|
||||
class="hidden absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<!-- Preview pane -->
|
||||
<div id="merge-preview" class="mt-4 hidden">
|
||||
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">What will move</h4>
|
||||
<div id="merge-preview-body" class="space-y-3"></div>
|
||||
</div>
|
||||
|
||||
<div id="merge-error" class="hidden mt-3 text-sm text-red-600"></div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
||||
<button onclick="closeMergeModal()"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="merge-confirm-btn"
|
||||
onclick="confirmMerge()"
|
||||
disabled
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
Merge (permanent)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const _SOURCE_PROJECT_ID = "{{ project.id }}";
|
||||
const _SOURCE_PROJECT_NAME = {{ project.name|tojson }};
|
||||
|
||||
function _mergeEsc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function openMergeModal() {
|
||||
document.getElementById('merge-target-input').value = '';
|
||||
document.getElementById('merge-target-id').value = '';
|
||||
document.getElementById('merge-preview').classList.add('hidden');
|
||||
document.getElementById('merge-error').classList.add('hidden');
|
||||
document.getElementById('merge-confirm-btn').disabled = true;
|
||||
document.getElementById('merge-modal').classList.remove('hidden');
|
||||
setTimeout(() => document.getElementById('merge-target-input').focus(), 50);
|
||||
}
|
||||
|
||||
function closeMergeModal() {
|
||||
document.getElementById('merge-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
let _mergeTargetDebounce = null;
|
||||
async function onMergeTargetInput() {
|
||||
if (_mergeTargetDebounce) clearTimeout(_mergeTargetDebounce);
|
||||
_mergeTargetDebounce = setTimeout(_mergeFetchTargets, 150);
|
||||
}
|
||||
|
||||
async function _mergeFetchTargets() {
|
||||
const q = document.getElementById('merge-target-input').value.trim();
|
||||
const dropdown = document.getElementById('merge-target-dropdown');
|
||||
|
||||
let data;
|
||||
try {
|
||||
// Reuse the metadata-backfill projects_search endpoint — works for
|
||||
// any caller, returns existing-only (no create-new option needed here).
|
||||
const r = await fetch(`/api/admin/metadata_backfill/projects_search?q=${encodeURIComponent(q)}&limit=12`);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
data = await r.json();
|
||||
} catch (e) {
|
||||
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-red-500">Search failed: ${_mergeEsc(e.message)}</div>`;
|
||||
dropdown.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out self.
|
||||
const candidates = (data.matches || []).filter(m => m.id !== _SOURCE_PROJECT_ID);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">No matches.</div>`;
|
||||
dropdown.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.innerHTML = candidates.map(m => {
|
||||
const scoreBadge = m.score >= 0.99
|
||||
? '<span class="text-xs text-green-600 dark:text-green-400 ml-2">exact</span>'
|
||||
: `<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">${(m.score*100).toFixed(0)}%</span>`;
|
||||
const meta = [];
|
||||
if (m.project_number) meta.push(_mergeEsc(m.project_number));
|
||||
if (m.client_name) meta.push(_mergeEsc(m.client_name));
|
||||
if (m.location_count > 0) meta.push(`${m.location_count} location${m.location_count === 1 ? '' : 's'}`);
|
||||
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
|
||||
return `<button type="button"
|
||||
onmousedown="event.preventDefault()"
|
||||
onclick="onMergePickTarget('${_mergeEsc(m.id)}', ${JSON.stringify(m.name)})"
|
||||
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">${_mergeEsc(m.name)}${scoreBadge}</div>
|
||||
${metaLine}
|
||||
</button>`;
|
||||
}).join('');
|
||||
dropdown.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function onMergePickTarget(targetId, targetName) {
|
||||
document.getElementById('merge-target-input').value = targetName;
|
||||
document.getElementById('merge-target-id').value = targetId;
|
||||
document.getElementById('merge-target-dropdown').classList.add('hidden');
|
||||
await _loadMergePreview(targetId);
|
||||
}
|
||||
|
||||
async function _loadMergePreview(targetId) {
|
||||
const previewEl = document.getElementById('merge-preview');
|
||||
const bodyEl = document.getElementById('merge-preview-body');
|
||||
const errorEl = document.getElementById('merge-error');
|
||||
const confirmBtn = document.getElementById('merge-confirm-btn');
|
||||
|
||||
previewEl.classList.add('hidden');
|
||||
errorEl.classList.add('hidden');
|
||||
confirmBtn.disabled = true;
|
||||
bodyEl.innerHTML = '<div class="text-center py-3 text-sm text-gray-500"><div class="animate-spin rounded-full h-5 w-5 border-b-2 border-seismo-orange mx-auto mb-2"></div>Loading preview…</div>';
|
||||
previewEl.classList.remove('hidden');
|
||||
|
||||
let d;
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${_SOURCE_PROJECT_ID}/merge_preview?target_id=${encodeURIComponent(targetId)}`);
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(err.detail || ('HTTP ' + r.status));
|
||||
}
|
||||
d = await r.json();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
previewEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Merging <strong>"${_mergeEsc(d.source_project_name)}"</strong> into <strong>"${_mergeEsc(d.target_project_name)}"</strong>:
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 mt-2">
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded p-2"><div class="text-xs text-gray-500 dark:text-gray-400">Assignments</div><div class="text-lg font-bold">${d.total_assignments_moving}</div></div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded p-2"><div class="text-xs text-gray-500 dark:text-gray-400">Sessions</div><div class="text-lg font-bold">${d.total_sessions_moving}</div></div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded p-2"><div class="text-xs text-gray-500 dark:text-gray-400">Data files</div><div class="text-lg font-bold">${d.total_data_files_moving}</div></div>
|
||||
</div>`;
|
||||
|
||||
if (d.location_plans.length > 0) {
|
||||
html += `<div class="mt-3">
|
||||
<h5 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">Locations</h5>
|
||||
<div class="space-y-1 text-sm">`;
|
||||
for (const p of d.location_plans) {
|
||||
if (p.action === 'consolidate') {
|
||||
html += `<div class="text-gray-700 dark:text-gray-300">
|
||||
🔀 <strong>${_mergeEsc(p.source_name)}</strong> → consolidates into existing target <strong>"${_mergeEsc(p.target_name)}"</strong>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">(${p.assignments_moving} assignments + ${p.sessions_moving} sessions move)</span>
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="text-gray-700 dark:text-gray-300">
|
||||
→ <strong>${_mergeEsc(p.source_name)}</strong> moves to target as-is
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">(${p.assignments_moving} assignments + ${p.sessions_moving} sessions)</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
if (d.modules_to_add.length > 0) {
|
||||
html += `<div class="mt-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
Modules to add to target: ${d.modules_to_add.map(m => `<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs">${_mergeEsc(m)}</code>`).join(' ')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (d.warnings.length > 0) {
|
||||
html += '<div class="mt-3 space-y-2">';
|
||||
for (const w of d.warnings) {
|
||||
html += `<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded p-2 text-xs text-amber-800 dark:text-amber-300">⚠ ${_mergeEsc(w)}</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += `<div class="mt-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-2 text-xs text-red-800 dark:text-red-300">
|
||||
<strong>⚠ This action is destructive.</strong> Source project will be soft-deleted (status='deleted').
|
||||
Audit rows will be written to unit_history for every moved assignment.
|
||||
</div>`;
|
||||
|
||||
bodyEl.innerHTML = html;
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
|
||||
async function confirmMerge() {
|
||||
const targetId = document.getElementById('merge-target-id').value;
|
||||
if (!targetId) return;
|
||||
const confirmBtn = document.getElementById('merge-confirm-btn');
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Merging…';
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${_SOURCE_PROJECT_ID}/merge_into?target_id=${encodeURIComponent(targetId)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(err.detail || ('HTTP ' + r.status));
|
||||
}
|
||||
const d = await r.json();
|
||||
// Redirect to the target project — source no longer exists in active state.
|
||||
window.location.href = `/projects/${d.target_project_id}`;
|
||||
} catch (e) {
|
||||
const errorEl = document.getElementById('merge-error');
|
||||
errorEl.textContent = 'Merge failed: ' + e.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Merge (permanent)';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add Module Modal -->
|
||||
<div id="add-module-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm mx-4 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Add Module</h3>
|
||||
<button onclick="closeAddModuleModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="add-module-options" class="space-y-2">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<p id="add-module-none" class="hidden text-sm text-gray-500 dark:text-gray-400 text-center py-4">All available modules are already enabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const _MODULE_META = {
|
||||
sound_monitoring: { name: "Sound Monitoring", color: "orange", icon: "M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072" },
|
||||
vibration_monitoring: { name: "Vibration Monitoring", color: "blue", icon: "M22 12h-4l-3 9L9 3l-3 9H2" },
|
||||
};
|
||||
|
||||
async function openAddModuleModal() {
|
||||
const resp = await fetch(`/api/projects/${projectId}/modules`);
|
||||
const data = await resp.json();
|
||||
const container = document.getElementById('add-module-options');
|
||||
const none = document.getElementById('add-module-none');
|
||||
container.innerHTML = '';
|
||||
if (!data.available || data.available.length === 0) {
|
||||
none.classList.remove('hidden');
|
||||
} else {
|
||||
none.classList.add('hidden');
|
||||
data.available.forEach(m => {
|
||||
const meta = _MODULE_META[m.module_type] || { name: m.module_type, color: 'gray' };
|
||||
const btn = document.createElement('button');
|
||||
btn.className = `w-full text-left px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-${meta.color}-400 hover:bg-${meta.color}-50 dark:hover:bg-${meta.color}-900/20 transition-colors flex items-center gap-3`;
|
||||
btn.innerHTML = `<span class="flex-1 font-medium text-gray-900 dark:text-white">${meta.name}</span>`;
|
||||
btn.onclick = () => addModule(m.module_type);
|
||||
container.appendChild(btn);
|
||||
});
|
||||
}
|
||||
document.getElementById('add-module-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeAddModuleModal() {
|
||||
document.getElementById('add-module-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function addModule(moduleType) {
|
||||
await fetch(`/api/projects/${projectId}/modules`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ module_type: moduleType }),
|
||||
});
|
||||
closeAddModuleModal();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function removeModule(moduleType) {
|
||||
const meta = _MODULE_META[moduleType] || { name: moduleType };
|
||||
if (!confirm(`Remove the ${meta.name} module? The data will not be deleted, but the related tabs will be hidden.`)) return;
|
||||
await fetch(`/api/projects/${projectId}/modules/${moduleType}`, { method: 'DELETE' });
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.project.status == 'active' %}
|
||||
{% if item.project.status == 'upcoming' %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
|
||||
{% elif item.project.status == 'active' %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||
{% elif item.project.status == 'on_hold' %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% set s = item.session %}
|
||||
{% set loc = item.location %}
|
||||
{% set unit = item.unit %}
|
||||
{% set effective_range = item.effective_range %}
|
||||
|
||||
{# Period display maps #}
|
||||
{% set period_labels = {
|
||||
@@ -49,25 +50,74 @@
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded-full">Failed</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Period type badge (click to change) -->
|
||||
<!-- Period type badge (click to open hour editor) -->
|
||||
<div class="relative" id="period-wrap-{{ s.id }}">
|
||||
<button onclick="togglePeriodMenu('{{ s.id }}')"
|
||||
<button onclick="openPeriodEditor('{{ s.id }}')"
|
||||
id="period-badge-{{ s.id }}"
|
||||
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
||||
title="Click to change period type">
|
||||
title="Click to edit period type and hours">
|
||||
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
|
||||
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="period-menu-{{ s.id }}"
|
||||
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg min-w-[160px] py-1">
|
||||
{% for pt, pt_label in [('weekday_day','Weekday Day'),('weekday_night','Weekday Night'),('weekend_day','Weekend Day'),('weekend_night','Weekend Night')] %}
|
||||
<button onclick="setPeriodType('{{ s.id }}', '{{ pt }}')"
|
||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 {% if s.period_type == pt %}font-bold{% endif %}">
|
||||
{{ pt_label }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Period editor panel -->
|
||||
<div id="period-editor-{{ s.id }}"
|
||||
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg w-64 p-3 space-y-3">
|
||||
|
||||
<!-- Period type selector -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Period Type</label>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
{% for pt, pt_label in [('weekday_day','WD Day'),('weekday_night','WD Night'),('weekend_day','WE Day'),('weekend_night','WE Night')] %}
|
||||
<button onclick="selectPeriodType('{{ s.id }}', '{{ pt }}')"
|
||||
id="pt-btn-{{ s.id }}-{{ pt }}"
|
||||
class="period-type-btn text-xs py-1 px-2 rounded border transition-colors
|
||||
{% if s.period_type == pt %}border-seismo-orange bg-orange-50 text-seismo-orange dark:bg-orange-900/20{% else %}border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-400{% endif %}">
|
||||
{{ pt_label }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hour inputs -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Hour (0–23)</label>
|
||||
<input type="number" min="0" max="23"
|
||||
id="period-start-hr-{{ s.id }}"
|
||||
value="{{ s.period_start_hour if s.period_start_hour is not none else '' }}"
|
||||
placeholder="e.g. 19"
|
||||
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Hour (0–23)</label>
|
||||
<input type="number" min="0" max="23"
|
||||
id="period-end-hr-{{ s.id }}"
|
||||
value="{{ s.period_end_hour if s.period_end_hour is not none else '' }}"
|
||||
placeholder="e.g. 7"
|
||||
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">Day: 7→19 · Night: 19→7 · Customize as needed</p>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button onclick="savePeriodEditor('{{ s.id }}')"
|
||||
class="flex-1 text-xs py-1 bg-seismo-orange text-white rounded hover:bg-orange-600 transition-colors">
|
||||
Save
|
||||
</button>
|
||||
<button onclick="closePeriodEditor('{{ s.id }}')"
|
||||
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button onclick="clearPeriodEditor('{{ s.id }}')"
|
||||
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-500 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors"
|
||||
title="Clear period type and hours">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,8 +181,17 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if s.notes %}
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 italic">{{ s.notes }}</p>
|
||||
<!-- Effective window (when period hours are set) -->
|
||||
{% if effective_range %}
|
||||
<div class="flex items-center gap-1 mt-1.5 text-xs text-indigo-600 dark:text-indigo-400">
|
||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span id="effective-range-{{ s.id }}">Effective: {{ effective_range }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="hidden text-xs text-indigo-600 dark:text-indigo-400 mt-1.5"
|
||||
id="effective-range-{{ s.id }}"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -163,6 +222,8 @@
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
const PROJECT_ID = '{{ project_id }}';
|
||||
|
||||
const PERIOD_COLORS = {
|
||||
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||
@@ -175,46 +236,145 @@ const PERIOD_LABELS = {
|
||||
weekend_day: 'Weekend Day',
|
||||
weekend_night: 'Weekend Night',
|
||||
};
|
||||
// Default hours for each period type
|
||||
const PERIOD_DEFAULT_HOURS = {
|
||||
weekday_day: {start: 7, end: 19},
|
||||
weekday_night: {start: 19, end: 7},
|
||||
weekend_day: {start: 7, end: 19},
|
||||
weekend_night: {start: 19, end: 7},
|
||||
};
|
||||
const FALLBACK_COLORS = ['bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400'];
|
||||
const ALL_BADGE_COLORS = [...new Set([
|
||||
...FALLBACK_COLORS,
|
||||
...Object.values(PERIOD_COLORS).flatMap(s => s.split(' '))
|
||||
])];
|
||||
|
||||
function togglePeriodMenu(sessionId) {
|
||||
const menu = document.getElementById('period-menu-' + sessionId);
|
||||
document.querySelectorAll('[id^="period-menu-"]').forEach(m => {
|
||||
if (m.id !== 'period-menu-' + sessionId) m.classList.add('hidden');
|
||||
// Track which period type is selected in the editor before saving
|
||||
const _editorState = {};
|
||||
|
||||
// ---- Period editor ----
|
||||
|
||||
function openPeriodEditor(sessionId) {
|
||||
// Close all other editors first
|
||||
document.querySelectorAll('[id^="period-editor-"]').forEach(el => {
|
||||
if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden');
|
||||
});
|
||||
menu.classList.toggle('hidden');
|
||||
document.getElementById('period-editor-' + sessionId).classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function closePeriodEditor(sessionId) {
|
||||
document.getElementById('period-editor-' + sessionId).classList.add('hidden');
|
||||
delete _editorState[sessionId];
|
||||
}
|
||||
|
||||
function selectPeriodType(sessionId, pt) {
|
||||
_editorState[sessionId] = pt;
|
||||
// Highlight selected button
|
||||
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
|
||||
const isSelected = btn.id === `pt-btn-${sessionId}-${pt}`;
|
||||
btn.classList.toggle('border-seismo-orange', isSelected);
|
||||
btn.classList.toggle('bg-orange-50', isSelected);
|
||||
btn.classList.toggle('text-seismo-orange', isSelected);
|
||||
btn.classList.toggle('dark:bg-orange-900/20', isSelected);
|
||||
btn.classList.toggle('border-gray-200', !isSelected);
|
||||
btn.classList.toggle('dark:border-gray-600', !isSelected);
|
||||
btn.classList.toggle('text-gray-600', !isSelected);
|
||||
btn.classList.toggle('dark:text-gray-400', !isSelected);
|
||||
});
|
||||
// Fill default hours
|
||||
const defaults = PERIOD_DEFAULT_HOURS[pt];
|
||||
if (defaults) {
|
||||
const sh = document.getElementById('period-start-hr-' + sessionId);
|
||||
const eh = document.getElementById('period-end-hr-' + sessionId);
|
||||
if (sh && !sh.value) sh.value = defaults.start;
|
||||
if (eh && !eh.value) eh.value = defaults.end;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePeriodEditor(sessionId) {
|
||||
const pt = _editorState[sessionId] || document.getElementById('period-badge-' + sessionId)
|
||||
?.dataset?.currentPeriod || null;
|
||||
const shInput = document.getElementById('period-start-hr-' + sessionId);
|
||||
const ehInput = document.getElementById('period-end-hr-' + sessionId);
|
||||
|
||||
const payload = {};
|
||||
if (pt !== undefined) payload.period_type = pt || null;
|
||||
payload.period_start_hour = shInput?.value !== '' ? parseInt(shInput.value, 10) : null;
|
||||
payload.period_end_hour = ehInput?.value !== '' ? parseInt(ehInput.value, 10) : null;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
const result = await resp.json();
|
||||
|
||||
// Update badge
|
||||
const badge = document.getElementById('period-badge-' + sessionId);
|
||||
const label = document.getElementById('period-label-' + sessionId);
|
||||
const newPt = result.period_type;
|
||||
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
|
||||
if (newPt && PERIOD_COLORS[newPt]) {
|
||||
badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean));
|
||||
if (label) label.textContent = PERIOD_LABELS[newPt];
|
||||
} else {
|
||||
badge.classList.add(...FALLBACK_COLORS);
|
||||
if (label) label.textContent = 'Set period';
|
||||
}
|
||||
|
||||
// Update effective range display
|
||||
_updateEffectiveRange(sessionId, result.period_start_hour, result.period_end_hour);
|
||||
|
||||
closePeriodEditor(sessionId);
|
||||
} catch (err) {
|
||||
alert('Failed to save period: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPeriodEditor(sessionId) {
|
||||
const shInput = document.getElementById('period-start-hr-' + sessionId);
|
||||
const ehInput = document.getElementById('period-end-hr-' + sessionId);
|
||||
if (shInput) shInput.value = '';
|
||||
if (ehInput) ehInput.value = '';
|
||||
_editorState[sessionId] = null;
|
||||
|
||||
// Reset period type button highlights
|
||||
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
|
||||
btn.classList.remove('border-seismo-orange','bg-orange-50','text-seismo-orange','dark:bg-orange-900/20');
|
||||
btn.classList.add('border-gray-200','dark:border-gray-600','text-gray-600','dark:text-gray-400');
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Effective range helper ----
|
||||
|
||||
function _updateEffectiveRange(sessionId, startHour, endHour) {
|
||||
const el = document.getElementById('effective-range-' + sessionId);
|
||||
if (!el) return;
|
||||
if (startHour == null || endHour == null) {
|
||||
el.textContent = '';
|
||||
el.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
function _fmt(h) {
|
||||
const ampm = h < 12 ? 'AM' : 'PM';
|
||||
const h12 = h % 12 || 12;
|
||||
return `${h12}:00 ${ampm}`;
|
||||
}
|
||||
// We don't have the session start date in JS so just show the hours pattern
|
||||
el.textContent = `Effective window: ${_fmt(startHour)} → ${_fmt(endHour)}`;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ---- Close editors on outside click ----
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('[id^="period-wrap-"]')) {
|
||||
document.querySelectorAll('[id^="period-menu-"]').forEach(m => m.classList.add('hidden'));
|
||||
document.querySelectorAll('[id^="period-editor-"]').forEach(m => m.classList.add('hidden'));
|
||||
}
|
||||
});
|
||||
|
||||
async function setPeriodType(sessionId, periodType) {
|
||||
document.getElementById('period-menu-' + sessionId).classList.add('hidden');
|
||||
const badge = document.getElementById('period-badge-' + sessionId);
|
||||
badge.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({period_type: periodType}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
|
||||
badge.classList.add(...(PERIOD_COLORS[periodType] || FALLBACK_COLORS.join(' ')).split(' ').filter(Boolean));
|
||||
document.getElementById('period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
|
||||
} catch(err) {
|
||||
alert('Failed to update period type: ' + err.message);
|
||||
} finally {
|
||||
badge.disabled = false;
|
||||
}
|
||||
}
|
||||
// ---- Label editing ----
|
||||
|
||||
function startEditLabel(sessionId) {
|
||||
document.getElementById('label-display-' + sessionId).classList.add('hidden');
|
||||
@@ -234,7 +394,7 @@ async function saveLabel(sessionId) {
|
||||
const input = document.getElementById('label-input-' + sessionId);
|
||||
const newLabel = input.value.trim();
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
||||
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({session_label: newLabel}),
|
||||
@@ -249,8 +409,10 @@ async function saveLabel(sessionId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Session details ----
|
||||
|
||||
function viewSession(sessionId) {
|
||||
alert('Session details coming soon: ' + sessionId);
|
||||
window.location.href = `/api/projects/${PROJECT_ID}/sessions/${sessionId}/detail`;
|
||||
}
|
||||
|
||||
function stopRecording(sessionId) {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<!-- Monthly Sessions Calendar — Gantt Style -->
|
||||
<div class="sessions-cal-wrap bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
|
||||
<!-- Month navigation -->
|
||||
<div class="px-5 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ prev_month }}&year={{ prev_year }}"
|
||||
hx-target="#sessions-calendar"
|
||||
hx-swap="innerHTML"
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
|
||||
title="Previous month">
|
||||
<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="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ month_name }} {{ year }}</h3>
|
||||
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ next_month }}&year={{ next_year }}"
|
||||
hx-target="#sessions-calendar"
|
||||
hx-swap="innerHTML"
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
|
||||
title="Next month">
|
||||
<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="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Legend + key -->
|
||||
<div class="px-5 py-2 border-b border-gray-100 dark:border-gray-700 flex flex-wrap items-center gap-x-5 gap-y-1.5">
|
||||
{% if legend %}
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{% for loc in legend %}
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="w-2.5 h-2.5 rounded-full shrink-0" style="background-color: {{ loc.color }}"></span>
|
||||
<span>{{ loc.name }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Bar key -->
|
||||
<div class="flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500 ml-auto shrink-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-8 h-2 rounded-sm" style="background:rgba(100,100,100,0.25)"></div>
|
||||
<span>Device on</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-8 h-2 rounded-sm bg-blue-500"></div>
|
||||
<span>Effective window</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Day-of-week headers -->
|
||||
<div class="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
|
||||
{% for day_name in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] %}
|
||||
<div class="py-2 text-center text-xs font-medium uppercase tracking-wide
|
||||
{% if loop.index == 1 or loop.index == 7 %}text-amber-500 dark:text-amber-400{% else %}text-gray-400 dark:text-gray-500{% endif %}">
|
||||
{{ day_name }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Calendar grid -->
|
||||
{% for week in weeks %}
|
||||
<div class="grid grid-cols-7 {% if not loop.last %}border-b border-gray-100 dark:border-gray-700{% endif %}">
|
||||
{% for day in week %}
|
||||
<div class="min-h-[80px] p-1.5
|
||||
{% if not loop.last %}border-r border-gray-100 dark:border-gray-700{% endif %}
|
||||
{% if not day.in_month %}bg-gray-50 dark:bg-slate-800/50{% else %}bg-white dark:bg-slate-800{% endif %}
|
||||
{% if day.is_today %}ring-1 ring-inset ring-seismo-orange{% endif %}">
|
||||
|
||||
<!-- Date number -->
|
||||
<div class="text-right mb-1.5">
|
||||
<span class="text-xs {% if day.is_today %}font-bold text-seismo-orange{% elif day.in_month %}text-gray-700 dark:text-gray-300{% else %}text-gray-300 dark:text-gray-600{% endif %}">
|
||||
{{ day.date.day }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Gantt bars -->
|
||||
{% if day.sessions %}
|
||||
<div class="space-y-2">
|
||||
{% for s in day.sessions %}
|
||||
|
||||
<a href="/api/projects/{{ project_id }}/sessions/{{ s.session_id }}/detail" class="block">
|
||||
|
||||
<!-- 24-hour timeline bar -->
|
||||
<div class="relative overflow-hidden -mx-1.5" style="height:11px; background:rgba(128,128,128,0.08);">
|
||||
<div class="absolute top-0 bottom-0 w-px" style="left:25%; background:rgba(128,128,128,0.18)"></div>
|
||||
<div class="absolute top-0 bottom-0 w-px" style="left:50%; background:rgba(128,128,128,0.28)"></div>
|
||||
<div class="absolute top-0 bottom-0 w-px" style="left:75%; background:rgba(128,128,128,0.18)"></div>
|
||||
<div class="absolute top-0 bottom-0"
|
||||
style="left:{{ s.dev_start_pct }}%; width:{{ s.dev_width_pct }}%; background-color:{{ s.color }}; opacity:0.28;"></div>
|
||||
{% if s.eff_start_pct is not none %}
|
||||
<div class="absolute"
|
||||
style="left:{{ s.eff_start_pct }}%; width:{{ s.eff_width_pct }}%; top:1.5px; bottom:1.5px; background-color:{{ s.color }};"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<div class="truncate mt-0.5" style="color:{{ s.color }}; font-size:0.58rem; line-height:1.3;">
|
||||
{{ s.location_name }} · {{ day.date.strftime('%-m/%-d') }} · {% if s.period_type %}{{ 'Night' if 'night' in s.period_type else 'Day' }}{% else %}—{% endif %}
|
||||
</div>
|
||||
|
||||
</a>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Time scale footer hint -->
|
||||
<div class="px-4 py-1.5 border-t border-gray-100 dark:border-gray-700 flex justify-between text-gray-300 dark:text-gray-600" style="font-size:0.6rem;">
|
||||
<span>12 AM</span>
|
||||
<span>6 AM</span>
|
||||
<span>12 PM</span>
|
||||
<span>6 PM</span>
|
||||
<span>12 AM</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,15 @@
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||
{{ session.status or 'unknown' }}
|
||||
</span>
|
||||
<!-- Edit Session Times -->
|
||||
<button onclick="event.stopPropagation(); openEditSessionModal('{{ session.id }}', '{{ session.started_at|local_datetime if session.started_at else '' }}', '{{ session.stopped_at|local_datetime if session.stopped_at else '' }}')"
|
||||
class="px-3 py-1 text-xs bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
|
||||
title="Edit session times">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<!-- Download All Files in Session -->
|
||||
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
|
||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
|
||||
@@ -326,4 +335,74 @@ async function deleteSession(sessionId) {
|
||||
alert(`Error deleting session: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function openEditSessionModal(sessionId, startedAt, stoppedAt) {
|
||||
document.getElementById('editSessionId').value = sessionId;
|
||||
// local_datetime filter returns "YYYY-MM-DD HH:MM" — convert to "YYYY-MM-DDTHH:MM" for datetime-local input
|
||||
document.getElementById('editStartedAt').value = startedAt ? startedAt.replace(' ', 'T') : '';
|
||||
document.getElementById('editStoppedAt').value = stoppedAt ? stoppedAt.replace(' ', 'T') : '';
|
||||
document.getElementById('editSessionModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeEditSessionModal() {
|
||||
document.getElementById('editSessionModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function saveSessionTimes() {
|
||||
const sessionId = document.getElementById('editSessionId').value;
|
||||
const startedAt = document.getElementById('editStartedAt').value;
|
||||
const stoppedAt = document.getElementById('editStoppedAt').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
started_at: startedAt || null,
|
||||
stopped_at: stoppedAt || null,
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
closeEditSessionModal();
|
||||
window.location.reload();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(`Failed to update session: ${data.detail || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error updating session: ${error.message}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Edit Session Times Modal -->
|
||||
<div id="editSessionModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm mx-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Session Times</h3>
|
||||
<input type="hidden" id="editSessionId">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Time</label>
|
||||
<input type="datetime-local" id="editStartedAt"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stop Time</label>
|
||||
<input type="datetime-local" id="editStoppedAt"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">Times are in your local timezone. The session label and period type will be updated automatically.</p>
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button onclick="closeEditSessionModal()"
|
||||
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button onclick="saveSessionTimes()"
|
||||
class="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
{# Project-wide vibration events roll-up. Loaded via HTMX. #}
|
||||
{% if summary.vibration_location_count == 0 %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
No vibration monitoring locations yet. Add one to start collecting events.
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Project-wide vibration events
|
||||
</h3>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
across {{ summary.vibration_location_count }} location{{ '' if summary.vibration_location_count == 1 else 's' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- KPI tiles -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
|
||||
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">{{ "{:,}".format(summary.total_events) }}</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Overall Peak</span>
|
||||
{% if summary.peak_pvs is not none %}
|
||||
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">{{ "%.4f"|format(summary.peak_pvs) }} <span class="text-sm font-normal">in/s</span></span>
|
||||
<a href="/projects/{{ summary.project_id }}/nrl/{{ summary.peak_pvs_location_id }}"
|
||||
class="text-xs text-seismo-orange hover:text-seismo-navy truncate mt-1"
|
||||
title="{{ summary.peak_pvs_location_name }}">
|
||||
{{ summary.peak_pvs_location_name }}
|
||||
{% if summary.peak_pvs_at %} · {{ summary.peak_pvs_at[:10] }}{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
|
||||
{% if summary.last_event %}
|
||||
<span class="text-base font-bold text-gray-900 dark:text-white mt-1">{{ summary.last_event[:19].replace('T', ' ') }}</span>
|
||||
{% else %}
|
||||
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">False Triggers</span>
|
||||
<span class="text-2xl font-bold {% if summary.false_trigger_count > 0 %}text-amber-600 dark:text-amber-400{% else %}text-gray-900 dark:text-white{% endif %} mt-1">{{ "{:,}".format(summary.false_trigger_count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if summary.per_location and summary.total_events > 0 %}
|
||||
<!-- Top locations by activity -->
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Top locations by activity</h4>
|
||||
<div class="space-y-1.5">
|
||||
{% for loc in summary.per_location[:5] %}
|
||||
<a href="/projects/{{ summary.project_id }}/nrl/{{ loc.location_id }}"
|
||||
class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
📍 {{ loc.location_name }}
|
||||
</span>
|
||||
<span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3">
|
||||
<span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span>
|
||||
{% if loc.peak_pvs is not none %}
|
||||
<span class="text-xs text-gray-500">peak {{ "%.4f"|format(loc.peak_pvs) }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,63 @@
|
||||
<tr id="seismo-row-{{ unit.id }}" class="bg-blue-50 dark:bg-slate-600 transition-colors">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap">
|
||||
<select name="status"
|
||||
class="text-xs rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<option value="deployed" {% if unit.deployed %}selected{% endif %}>Deployed</option>
|
||||
<option value="out_for_calibration" {% if unit.out_for_calibration %}selected{% endif %}>Out for Cal</option>
|
||||
<option value="benched" {% if not unit.deployed and not unit.out_for_calibration %}selected{% endif %}>Benched</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.deployed_with_modem_id }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if unit.address %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||
{% elif unit.coordinates %}
|
||||
<span>{{ unit.coordinates }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap">
|
||||
<input type="date"
|
||||
name="last_calibrated"
|
||||
value="{{ unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '' }}"
|
||||
class="text-xs rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<input type="text"
|
||||
name="note"
|
||||
value="{{ unit.note or '' }}"
|
||||
placeholder="Add a note..."
|
||||
class="w-full text-sm rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-right text-sm">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button hx-post="/api/seismo-dashboard/unit/{{ unit.id }}/quick-update"
|
||||
hx-include="closest tr"
|
||||
hx-target="#seismo-row-{{ unit.id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
||||
Save
|
||||
</button>
|
||||
<button hx-get="/api/seismo-dashboard/unit/{{ unit.id }}/view-row"
|
||||
hx-target="#seismo-row-{{ unit.id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -0,0 +1,93 @@
|
||||
<tr id="seismo-row-{{ unit.id }}" class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{% if unit.deployed %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Deployed
|
||||
</span>
|
||||
{% elif unit.out_for_calibration %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Out for Cal
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Benched
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.deployed_with_modem_id }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.address %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||
{% elif unit.coordinates %}
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.last_calibrated %}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
{% if unit.next_calibration_due and today %}
|
||||
{% set days_until = (unit.next_calibration_due - today).days %}
|
||||
{% if days_until < 0 %}
|
||||
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
|
||||
{% elif days_until <= 14 %}
|
||||
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
|
||||
{% endif %}
|
||||
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
||||
{% if unit.note %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button hx-get="/api/seismo-dashboard/unit/{{ unit.id }}/edit-row"
|
||||
hx-target="#seismo-row-{{ unit.id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||
title="Edit row">
|
||||
<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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View Details →
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -92,88 +92,7 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for unit in units %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{% if unit.deployed %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Deployed
|
||||
</span>
|
||||
{% elif unit.out_for_calibration %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Out for Cal
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Benched
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.deployed_with_modem_id }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.address %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||
{% elif unit.coordinates %}
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.last_calibrated %}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
{% if unit.next_calibration_due and today %}
|
||||
{% set days_until = (unit.next_calibration_due - today).days %}
|
||||
{% if days_until < 0 %}
|
||||
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
|
||||
{% elif days_until <= 14 %}
|
||||
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
|
||||
{% endif %}
|
||||
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
||||
{% if unit.note %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View Details →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% include "partials/seismo_row_view.html" %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+391
-280
@@ -35,30 +35,21 @@
|
||||
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange whitespace-nowrap">
|
||||
Overview
|
||||
</button>
|
||||
<button onclick="switchTab('locations')"
|
||||
data-tab="locations"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||
<span id="locations-tab-label">Locations</span>
|
||||
<button id="vibration-tab-btn" onclick="switchTab('vibration')"
|
||||
data-tab="vibration"
|
||||
class="tab-button hidden px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||
<svg class="w-4 h-4 inline mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
Vibration
|
||||
</button>
|
||||
<button id="units-tab-btn" onclick="switchTab('units')"
|
||||
data-tab="units"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||
Assigned Units
|
||||
</button>
|
||||
<button id="schedules-tab-btn" onclick="switchTab('schedules')"
|
||||
data-tab="schedules"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||
Schedules
|
||||
</button>
|
||||
<button id="sessions-tab-btn" onclick="switchTab('sessions')"
|
||||
data-tab="sessions"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||
Monitoring Sessions
|
||||
</button>
|
||||
<button id="data-tab-btn" onclick="switchTab('data')"
|
||||
data-tab="data"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||
Data Files
|
||||
<button id="sound-tab-btn" onclick="switchTab('sound')"
|
||||
data-tab="sound"
|
||||
class="tab-button hidden px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||
<svg class="w-4 h-4 inline mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 14M8.464 8.464a5 5 0 000 7.072"/>
|
||||
</svg>
|
||||
Sound
|
||||
</button>
|
||||
<button onclick="switchTab('settings')"
|
||||
data-tab="settings"
|
||||
@@ -86,111 +77,104 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locations Tab -->
|
||||
<div id="locations-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
<span id="locations-header">Locations</span>
|
||||
</h2>
|
||||
<button onclick="openLocationModal()" id="add-location-btn"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
<span id="add-location-label">Add Location</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Vibration Tab -->
|
||||
<div id="vibration-tab" class="tab-panel hidden">
|
||||
<!-- Vibration sub-nav -->
|
||||
<div class="flex gap-0 mb-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<button id="vib-sub-locations-btn" onclick="switchVibSubTab('locations')"
|
||||
class="vib-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-seismo-orange text-seismo-orange -mb-px transition-colors whitespace-nowrap">
|
||||
Locations
|
||||
</button>
|
||||
<!-- Future sub-tabs: Sessions, Data Files -->
|
||||
</div>
|
||||
|
||||
<div id="project-locations"
|
||||
hx-get="/api/projects/{{ project_id }}/locations"
|
||||
<!-- Vibration Locations sub-panel -->
|
||||
<div id="vib-sub-locations" class="vib-sub-panel">
|
||||
<!-- Project-wide vibration events roll-up -->
|
||||
<div id="vibration-summary"
|
||||
hx-get="/api/projects/{{ project_id }}/vibration_summary"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
||||
hx-swap="innerHTML"
|
||||
class="mb-5">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Loading project summary…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
|
||||
<button onclick="openLocationModal('vibration')"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Add Location
|
||||
</button>
|
||||
</div>
|
||||
<div id="vibration-locations"
|
||||
hx-get="/api/projects/{{ project_id }}/locations?location_type=vibration"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Units Tab -->
|
||||
<div id="units-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Assigned Units</h2>
|
||||
<div class="text-sm text-gray-500">
|
||||
Units currently assigned to this project's locations
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="project-units"
|
||||
hx-get="/api/projects/{{ project_id }}/units"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading units...</div>
|
||||
</div>
|
||||
<!-- Sound Tab -->
|
||||
<div id="sound-tab" class="tab-panel hidden">
|
||||
<!-- Sound sub-nav -->
|
||||
<div class="flex gap-0 mb-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<button id="sound-sub-locations-btn" onclick="switchSoundSubTab('locations')"
|
||||
class="sound-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-seismo-orange text-seismo-orange -mb-px transition-colors whitespace-nowrap">
|
||||
NRLs
|
||||
</button>
|
||||
<button id="sound-sub-sessions-btn" onclick="switchSoundSubTab('sessions')"
|
||||
class="sound-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 -mb-px transition-colors whitespace-nowrap">
|
||||
Sessions
|
||||
</button>
|
||||
<button id="sound-sub-data-btn" onclick="switchSoundSubTab('data')"
|
||||
class="sound-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 -mb-px transition-colors whitespace-nowrap">
|
||||
Data Files
|
||||
</button>
|
||||
<button id="sound-sub-units-btn" onclick="switchSoundSubTab('units')"
|
||||
class="sound-sub-tab hidden px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 -mb-px transition-colors whitespace-nowrap">
|
||||
Assigned Units
|
||||
</button>
|
||||
<button id="sound-sub-schedules-btn" onclick="switchSoundSubTab('schedules')"
|
||||
class="sound-sub-tab hidden px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 -mb-px transition-colors whitespace-nowrap">
|
||||
Schedules
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedules Tab -->
|
||||
<div id="schedules-tab" class="tab-panel hidden">
|
||||
<!-- Recurring Schedules Section -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recurring Schedules</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Automated patterns that generate scheduled actions
|
||||
</p>
|
||||
<!-- NRLs sub-panel -->
|
||||
<div id="sound-sub-locations" class="sound-sub-panel">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Noise Recording Locations</h2>
|
||||
<button onclick="openLocationModal('sound')"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Add NRL
|
||||
</button>
|
||||
</div>
|
||||
<div id="sound-locations"
|
||||
hx-get="/api/projects/{{ project_id }}/locations?location_type=sound"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
||||
</div>
|
||||
<button onclick="openScheduleModal()"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Create Schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="recurring-schedule-list"
|
||||
hx-get="/api/projects/{{ project_id }}/recurring-schedules/partials/list"
|
||||
hx-trigger="load, refresh from:#recurring-schedule-list"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading recurring schedules...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduled Actions Section -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 id="schedules-title" class="text-xl font-semibold text-gray-900 dark:text-white">Upcoming Actions</h2>
|
||||
<p id="schedules-subtitle" class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Scheduled start/stop/download actions
|
||||
</p>
|
||||
</div>
|
||||
<select id="schedules-filter" onchange="filterScheduledActions()"
|
||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
<option value="pending">Pending</option>
|
||||
<option value="all">All</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="project-schedules"
|
||||
hx-get="/api/projects/{{ project_id }}/schedules?status=pending"
|
||||
hx-trigger="load, every 30s, refresh from:#project-schedules"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading scheduled actions...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monitoring Sessions Tab -->
|
||||
<div id="sessions-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Sessions sub-panel -->
|
||||
<div id="sound-sub-sessions" class="sound-sub-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||
<select id="sessions-filter" onchange="filterSessions()"
|
||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
<option value="all">All Sessions</option>
|
||||
@@ -199,103 +183,167 @@
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="project-sessions"
|
||||
hx-get="/api/projects/{{ project_id }}/sessions"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="project-sessions"
|
||||
hx-get="/api/projects/{{ project_id }}/sessions"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
|
||||
<!-- Monthly Calendar -->
|
||||
<div class="mt-6 bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Calendar View</h3>
|
||||
<div id="sessions-calendar"
|
||||
hx-get="/api/projects/{{ project_id }}/sessions-calendar"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-6 text-gray-400 text-sm">Loading calendar…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Files Tab -->
|
||||
<div id="data-tab" class="tab-panel hidden">
|
||||
<!-- FTP File Browser (Download from Devices) -->
|
||||
<div id="ftp-browser" class="mb-6"
|
||||
hx-get="/api/projects/{{ project_id }}/ftp-browser"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<!-- Data Files sub-panel -->
|
||||
<div id="sound-sub-data" class="sound-sub-panel hidden">
|
||||
<!-- FTP File Browser (remote projects only) -->
|
||||
<div id="ftp-browser" class="mb-6"
|
||||
hx-get="/api/projects/{{ project_id }}/ftp-browser"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="text-center py-8 text-gray-500">Loading FTP browser...</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Unified Files View -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Project Files</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="toggleUploadAll()"
|
||||
class="px-3 py-2 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||
</svg>
|
||||
Upload Data
|
||||
</button>
|
||||
<button onclick="htmx.trigger('#unified-files', 'refresh')"
|
||||
class="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload Data Panel -->
|
||||
<div id="upload-all-panel" class="hidden border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-800/50">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bulk Import — Select Folder</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Select your data folder directly — no zipping needed. Expected structure:
|
||||
<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">[date]/[NRL name]/[Auto_####]/</code>.
|
||||
NRL folders are matched to locations by name. MP3s are stored; Excel exports are skipped.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<input type="file" id="upload-all-input"
|
||||
webkitdirectory directory multiple
|
||||
class="block text-sm text-gray-500 dark:text-gray-400
|
||||
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
|
||||
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||
<span id="upload-all-file-count" class="text-xs text-gray-500 dark:text-gray-400 hidden"></span>
|
||||
<button id="upload-all-btn" onclick="submitUploadAll()"
|
||||
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||
Import
|
||||
</button>
|
||||
<button id="upload-all-cancel-btn" onclick="toggleUploadAll()"
|
||||
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<span id="upload-all-status" class="text-sm hidden"></span>
|
||||
</div>
|
||||
<div id="upload-all-progress-wrap" class="hidden mt-3">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
<span id="upload-all-progress-label">Uploading…</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div id="upload-all-progress-bar"
|
||||
class="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||
style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="unified-files"
|
||||
hx-get="/api/projects/{{ project_id }}/files-unified"
|
||||
hx-trigger="load, refresh from:#unified-files"
|
||||
hx-swap="innerHTML">
|
||||
<div class="px-6 py-12 text-center text-gray-500">Loading files...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assigned Units sub-panel (remote only) -->
|
||||
<div id="sound-sub-units" class="sound-sub-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="text-center py-8 text-gray-500">Loading FTP browser...</div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Assigned Units</h2>
|
||||
<div class="text-sm text-gray-500">Units currently assigned to this project's NRLs</div>
|
||||
</div>
|
||||
<div id="project-units"
|
||||
hx-get="/api/projects/{{ project_id }}/units"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading units...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unified Files View (Database + Filesystem) -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Project Files
|
||||
</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="toggleUploadAll()"
|
||||
class="px-3 py-2 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||
</svg>
|
||||
Upload Data
|
||||
</button>
|
||||
<button onclick="htmx.trigger('#unified-files', 'refresh')"
|
||||
class="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
<!-- Schedules sub-panel (remote only) -->
|
||||
<div id="sound-sub-schedules" class="sound-sub-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recurring Schedules</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Automated patterns that generate scheduled actions</p>
|
||||
</div>
|
||||
<button onclick="openScheduleModal()"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Create Schedule
|
||||
</button>
|
||||
</div>
|
||||
<div id="recurring-schedule-list"
|
||||
hx-get="/api/projects/{{ project_id }}/recurring-schedules/partials/list"
|
||||
hx-trigger="load, refresh from:#recurring-schedule-list"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading recurring schedules...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Data Panel -->
|
||||
<div id="upload-all-panel" class="hidden border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-800/50">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bulk Import — Select Folder</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Select your data folder directly — no zipping needed. Expected structure:
|
||||
<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">[date]/[NRL name]/[Auto_####]/</code>.
|
||||
NRL folders are matched to locations by name. MP3s are stored; Excel exports are skipped.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<input type="file" id="upload-all-input"
|
||||
webkitdirectory directory multiple
|
||||
class="block text-sm text-gray-500 dark:text-gray-400
|
||||
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
|
||||
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||
<span id="upload-all-file-count" class="text-xs text-gray-500 dark:text-gray-400 hidden"></span>
|
||||
<button id="upload-all-btn" onclick="submitUploadAll()"
|
||||
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||
Import
|
||||
</button>
|
||||
<button id="upload-all-cancel-btn" onclick="toggleUploadAll()"
|
||||
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<span id="upload-all-status" class="text-sm hidden"></span>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 id="schedules-title" class="text-xl font-semibold text-gray-900 dark:text-white">Upcoming Actions</h2>
|
||||
<p id="schedules-subtitle" class="text-sm text-gray-500 dark:text-gray-400 mt-1">Scheduled start/stop/download actions</p>
|
||||
</div>
|
||||
<!-- Progress bar -->
|
||||
<div id="upload-all-progress-wrap" class="hidden mt-3">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
<span id="upload-all-progress-label">Uploading…</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div id="upload-all-progress-bar"
|
||||
class="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||
style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Result summary -->
|
||||
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
|
||||
<select id="schedules-filter" onchange="filterScheduledActions()"
|
||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
<option value="pending">Pending</option>
|
||||
<option value="all">All</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="project-schedules"
|
||||
hx-get="/api/projects/{{ project_id }}/schedules?status=pending"
|
||||
hx-trigger="load, every 30s, refresh from:#project-schedules"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading scheduled actions...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="unified-files"
|
||||
hx-get="/api/projects/{{ project_id }}/files-unified"
|
||||
hx-trigger="load, refresh from:#unified-files"
|
||||
hx-swap="innerHTML">
|
||||
<div class="px-6 py-12 text-center text-gray-500">Loading files...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,6 +376,7 @@
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
||||
<select name="status" id="settings-status"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="on_hold">On Hold</option>
|
||||
<option value="completed">Completed</option>
|
||||
@@ -336,30 +385,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Data Collection</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex items-start gap-3 p-3 border-2 rounded-lg cursor-pointer" id="settings-mode-manual-label">
|
||||
<input type="radio" name="data_collection_mode" id="settings-mode-manual" value="manual"
|
||||
onchange="settingsUpdateModeStyles()"
|
||||
class="mt-0.5 accent-seismo-orange shrink-0">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Manual</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">SD card retrieved daily</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 p-3 border-2 rounded-lg cursor-pointer" id="settings-mode-remote-label">
|
||||
<input type="radio" name="data_collection_mode" id="settings-mode-remote" value="remote"
|
||||
onchange="settingsUpdateModeStyles()"
|
||||
class="mt-0.5 accent-seismo-orange shrink-0">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Remote</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Modem, data pulled via FTP</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Site Address</label>
|
||||
<input type="text" name="site_address" id="settings-site-address"
|
||||
@@ -386,6 +411,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sound-settings-section" class="hidden">
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-5 mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<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="M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 14M8.464 8.464a5 5 0 000 7.072"/>
|
||||
</svg>
|
||||
Sound Monitoring
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Data Collection</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex items-start gap-3 p-3 border-2 rounded-lg cursor-pointer" id="settings-mode-manual-label">
|
||||
<input type="radio" name="data_collection_mode" id="settings-mode-manual" value="manual"
|
||||
onchange="settingsUpdateModeStyles()"
|
||||
class="mt-0.5 accent-seismo-orange shrink-0">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Manual</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">SD card retrieved daily</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 p-3 border-2 rounded-lg cursor-pointer" id="settings-mode-remote-label">
|
||||
<input type="radio" name="data_collection_mode" id="settings-mode-remote" value="remote"
|
||||
onchange="settingsUpdateModeStyles()"
|
||||
class="mt-0.5 accent-seismo-orange shrink-0">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Remote</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Modem, data pulled via FTP</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings-success" class="hidden text-sm text-green-600 dark:text-green-400"></div>
|
||||
<div id="settings-error" class="hidden text-sm text-red-600"></div>
|
||||
|
||||
@@ -756,38 +815,74 @@
|
||||
<script>
|
||||
const projectId = "{{ project_id }}";
|
||||
let editingLocationId = null;
|
||||
let projectTypeId = null;
|
||||
let projectModules = []; // list of enabled module_type strings, e.g. ['sound_monitoring']
|
||||
|
||||
async function quickUpdateStatus(newStatus) {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
});
|
||||
if (response.ok) {
|
||||
// Reload the page to reflect new badge color and any side effects
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to update status');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error updating status');
|
||||
}
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
function switchTab(tabName) {
|
||||
// Hide all tab panels
|
||||
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||
panel.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Reset all tab buttons
|
||||
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.classList.remove('border-seismo-orange', 'text-seismo-orange');
|
||||
button.classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||
});
|
||||
|
||||
// Show selected tab panel
|
||||
const panel = document.getElementById(`${tabName}-tab`);
|
||||
if (panel) {
|
||||
panel.classList.remove('hidden');
|
||||
}
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
|
||||
// Highlight selected tab button
|
||||
const button = document.querySelector(`[data-tab="${tabName}"]`);
|
||||
if (button) {
|
||||
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||
}
|
||||
|
||||
// Persist active tab in URL hash so refresh stays on this tab
|
||||
history.replaceState(null, '', `#${tabName}`);
|
||||
}
|
||||
|
||||
function switchVibSubTab(name) {
|
||||
document.querySelectorAll('.vib-sub-panel').forEach(p => p.classList.add('hidden'));
|
||||
document.querySelectorAll('.vib-sub-tab').forEach(b => {
|
||||
b.classList.remove('border-seismo-orange', 'text-seismo-orange');
|
||||
b.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
|
||||
});
|
||||
document.getElementById(`vib-sub-${name}`)?.classList.remove('hidden');
|
||||
const btn = document.getElementById(`vib-sub-${name}-btn`);
|
||||
if (btn) {
|
||||
btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
|
||||
btn.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||
}
|
||||
}
|
||||
|
||||
function switchSoundSubTab(name) {
|
||||
document.querySelectorAll('.sound-sub-panel').forEach(p => p.classList.add('hidden'));
|
||||
document.querySelectorAll('.sound-sub-tab').forEach(b => {
|
||||
b.classList.remove('border-seismo-orange', 'text-seismo-orange');
|
||||
b.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
|
||||
});
|
||||
document.getElementById(`sound-sub-${name}`)?.classList.remove('hidden');
|
||||
const btn = document.getElementById(`sound-sub-${name}-btn`);
|
||||
if (btn) {
|
||||
btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
|
||||
btn.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||
}
|
||||
}
|
||||
|
||||
// Load project details
|
||||
async function loadProjectDetails() {
|
||||
try {
|
||||
@@ -796,7 +891,7 @@ async function loadProjectDetails() {
|
||||
throw new Error('Failed to load project details');
|
||||
}
|
||||
const data = await response.json();
|
||||
projectTypeId = data.project_type_id || null;
|
||||
projectModules = data.modules || [];
|
||||
|
||||
// Update breadcrumb
|
||||
document.getElementById('project-name-breadcrumb').textContent = data.name || 'Project';
|
||||
@@ -817,22 +912,20 @@ async function loadProjectDetails() {
|
||||
if (modeRadio) modeRadio.checked = true;
|
||||
settingsUpdateModeStyles();
|
||||
|
||||
// Update tab labels and visibility based on project type
|
||||
const isSoundProject = projectTypeId === 'sound_monitoring';
|
||||
if (isSoundProject) {
|
||||
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
||||
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
||||
document.getElementById('add-location-label').textContent = 'Add NRL';
|
||||
}
|
||||
// Monitoring Sessions and Data Files tabs are sound-only
|
||||
// Data Files also hides the FTP browser section for manual projects
|
||||
// Show/hide module tabs based on active modules
|
||||
const hasSoundModule = projectModules.includes('sound_monitoring');
|
||||
const hasVibrationModule = projectModules.includes('vibration_monitoring');
|
||||
const isRemote = mode === 'remote';
|
||||
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||
// Schedules and Assigned Units are remote-only (manual projects collect data by hand)
|
||||
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
|
||||
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
|
||||
// FTP browser within Data Files tab
|
||||
|
||||
document.getElementById('vibration-tab-btn').classList.toggle('hidden', !hasVibrationModule);
|
||||
document.getElementById('sound-tab-btn').classList.toggle('hidden', !hasSoundModule);
|
||||
document.getElementById('sound-settings-section')?.classList.toggle('hidden', !hasSoundModule);
|
||||
|
||||
// Within Sound: show Assigned Units + Schedules sub-tabs only for remote projects
|
||||
document.getElementById('sound-sub-units-btn')?.classList.toggle('hidden', !isRemote);
|
||||
document.getElementById('sound-sub-schedules-btn')?.classList.toggle('hidden', !isRemote);
|
||||
|
||||
// FTP browser: only show for remote projects
|
||||
document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote);
|
||||
|
||||
document.getElementById('settings-error').classList.add('hidden');
|
||||
@@ -950,7 +1043,14 @@ function updateModeLabels() {
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks the active location type tab so "Add Location" opens with the right type
|
||||
let _activeLocationType = null;
|
||||
function setActiveLocationType(type) {
|
||||
_activeLocationType = type;
|
||||
}
|
||||
|
||||
function openLocationModal(defaultType) {
|
||||
defaultType = defaultType || _activeLocationType || defaultType;
|
||||
editingLocationId = null;
|
||||
document.getElementById('location-modal-title').textContent = 'Add Location';
|
||||
document.getElementById('location-id').value = '';
|
||||
@@ -963,11 +1063,13 @@ function openLocationModal(defaultType) {
|
||||
if (connectedRadio) { connectedRadio.checked = true; updateModeLabels(); }
|
||||
const locationTypeSelect = document.getElementById('location-type');
|
||||
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||
if (projectTypeId === 'sound_monitoring') {
|
||||
const hasSoundMod = projectModules.includes('sound_monitoring');
|
||||
const hasVibMod = projectModules.includes('vibration_monitoring');
|
||||
if (hasSoundMod && !hasVibMod) {
|
||||
locationTypeSelect.value = 'sound';
|
||||
locationTypeSelect.disabled = true;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||
} else if (projectTypeId === 'vibration_monitoring') {
|
||||
} else if (hasVibMod && !hasSoundMod) {
|
||||
locationTypeSelect.value = 'vibration';
|
||||
locationTypeSelect.disabled = true;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||
@@ -997,11 +1099,13 @@ function openEditLocationModal(button) {
|
||||
if (modeRadio) { modeRadio.checked = true; updateModeLabels(); }
|
||||
const locationTypeSelect = document.getElementById('location-type');
|
||||
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||
if (projectTypeId === 'sound_monitoring') {
|
||||
const hasSoundModE = projectModules.includes('sound_monitoring');
|
||||
const hasVibModE = projectModules.includes('vibration_monitoring');
|
||||
if (hasSoundModE && !hasVibModE) {
|
||||
locationTypeSelect.value = 'sound';
|
||||
locationTypeSelect.disabled = true;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||
} else if (projectTypeId === 'vibration_monitoring') {
|
||||
} else if (hasVibModE && !hasSoundModE) {
|
||||
locationTypeSelect.value = 'vibration';
|
||||
locationTypeSelect.disabled = true;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||
@@ -1027,9 +1131,9 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
||||
const address = document.getElementById('location-address').value.trim();
|
||||
const coordinates = document.getElementById('location-coordinates').value.trim();
|
||||
let locationType = document.getElementById('location-type').value;
|
||||
if (projectTypeId === 'sound_monitoring') {
|
||||
if (projectModules.includes('sound_monitoring') && !projectModules.includes('vibration_monitoring')) {
|
||||
locationType = 'sound';
|
||||
} else if (projectTypeId === 'vibration_monitoring') {
|
||||
} else if (projectModules.includes('vibration_monitoring') && !projectModules.includes('sound_monitoring')) {
|
||||
locationType = 'vibration';
|
||||
}
|
||||
|
||||
@@ -1073,12 +1177,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
||||
}
|
||||
|
||||
closeLocationModal();
|
||||
refreshLocationLists();
|
||||
refreshProjectDashboard();
|
||||
// Refresh locations tab if visible
|
||||
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
||||
target: '#project-locations',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
} catch (err) {
|
||||
const errorEl = document.getElementById('location-error');
|
||||
errorEl.textContent = err.message || 'Failed to save location.';
|
||||
@@ -1086,6 +1186,15 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
||||
}
|
||||
});
|
||||
|
||||
function refreshLocationLists() {
|
||||
htmx.ajax('GET', `/api/projects/${projectId}/locations?location_type=sound`, {
|
||||
target: '#sound-locations', swap: 'innerHTML'
|
||||
});
|
||||
htmx.ajax('GET', `/api/projects/${projectId}/locations?location_type=vibration`, {
|
||||
target: '#vibration-locations', swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteLocation(locationId) {
|
||||
if (!confirm('Delete this location?')) return;
|
||||
|
||||
@@ -1097,11 +1206,8 @@ async function deleteLocation(locationId) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to delete location');
|
||||
}
|
||||
refreshLocationLists();
|
||||
refreshProjectDashboard();
|
||||
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
||||
target: '#project-locations',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to delete location.');
|
||||
}
|
||||
@@ -1175,11 +1281,8 @@ document.getElementById('assign-form').addEventListener('submit', async function
|
||||
throw new Error(data.detail || 'Failed to assign unit');
|
||||
}
|
||||
closeAssignModal();
|
||||
refreshLocationLists();
|
||||
refreshProjectDashboard();
|
||||
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
||||
target: '#project-locations',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
} catch (err) {
|
||||
const errorEl = document.getElementById('assign-error');
|
||||
errorEl.textContent = err.message || 'Failed to assign unit.';
|
||||
@@ -1198,11 +1301,8 @@ async function unassignUnit(assignmentId) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to unassign unit');
|
||||
}
|
||||
refreshLocationLists();
|
||||
refreshProjectDashboard();
|
||||
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
||||
target: '#project-locations',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to unassign unit.');
|
||||
}
|
||||
@@ -1802,12 +1902,23 @@ function submitUploadAll() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadProjectDetails();
|
||||
|
||||
// Restore tab from URL hash (e.g. #schedules, #settings)
|
||||
// Restore tab from URL hash
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
const validTabs = ['overview', 'locations', 'units', 'schedules', 'sessions', 'data', 'settings'];
|
||||
if (hash && validTabs.includes(hash)) {
|
||||
switchTab(hash);
|
||||
const validTabs = ['overview', 'vibration', 'sound', 'settings'];
|
||||
// Backwards compat: map old tab names to new ones
|
||||
const hashMap = { locations: 'sound', units: 'sound', schedules: 'sound', sessions: 'sound', data: 'sound' };
|
||||
const subTabMap = { units: 'units', schedules: 'schedules', sessions: 'sessions', data: 'data' };
|
||||
if (hash) {
|
||||
const mappedTab = hashMap[hash] || hash;
|
||||
if (validTabs.includes(mappedTab)) {
|
||||
switchTab(mappedTab);
|
||||
// Open the relevant sub-tab for backwards compat
|
||||
if (subTabMap[hash]) {
|
||||
switchSoundSubTab(subTabMap[hash]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+146
-116
@@ -36,12 +36,17 @@
|
||||
<nav class="flex space-x-8 px-6" aria-label="Tabs">
|
||||
<button onclick="switchTab('all')"
|
||||
id="tab-all"
|
||||
class="tab-button border-b-2 border-seismo-orange text-seismo-orange px-1 py-4 text-sm font-medium">
|
||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||
All Projects
|
||||
</button>
|
||||
<button onclick="switchTab('upcoming')"
|
||||
id="tab-upcoming"
|
||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||
Upcoming
|
||||
</button>
|
||||
<button onclick="switchTab('active')"
|
||||
id="tab-active"
|
||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||
class="tab-button border-b-2 border-seismo-orange text-seismo-orange px-1 py-4 text-sm font-medium">
|
||||
Active
|
||||
</button>
|
||||
<button onclick="switchTab('on_hold')"
|
||||
@@ -66,7 +71,7 @@
|
||||
<!-- Projects List -->
|
||||
<div id="projects-list"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
hx-get="/api/projects/list"
|
||||
hx-get="/api/projects/list?status=active"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<!-- Loading skeletons -->
|
||||
@@ -91,124 +96,122 @@
|
||||
</div>
|
||||
|
||||
<div class="p-6" id="createProjectContent">
|
||||
<!-- Step 1: Project Type Selection (initially shown) -->
|
||||
<div id="projectTypeSelection">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Choose Project Type</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
hx-get="/api/projects/types/list"
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML">
|
||||
<!-- Project type cards will be loaded here -->
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button type="button" onclick="hideCreateProjectModal()"
|
||||
class="px-6 py-2 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>
|
||||
</div>
|
||||
</div>
|
||||
<form id="createProjectFormElement">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Project Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
name="name"
|
||||
required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Project Details Form (hidden initially) -->
|
||||
<div id="projectDetailsForm" class="hidden">
|
||||
<button onclick="backToTypeSelection()"
|
||||
class="mb-4 text-seismo-orange hover:text-seismo-navy">
|
||||
← Back to project types
|
||||
</button>
|
||||
|
||||
<form id="createProjectFormElement"
|
||||
hx-post="/api/projects/create"
|
||||
hx-swap="none">
|
||||
<input type="hidden" id="project_type_id" name="project_type_id">
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Project Name *
|
||||
Project Number
|
||||
<span class="text-gray-400 font-normal">(xxxx-YY)</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
name="name"
|
||||
required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
name="project_number"
|
||||
pattern="\d{4}-\d{2}"
|
||||
placeholder="2567-23"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
Client Name
|
||||
</label>
|
||||
<textarea name="description"
|
||||
rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
||||
<input type="text"
|
||||
name="client_name"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Name
|
||||
</label>
|
||||
<input type="text"
|
||||
name="client_name"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Site Address
|
||||
</label>
|
||||
<input type="text"
|
||||
name="site_address"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Start Date
|
||||
</label>
|
||||
<input type="date"
|
||||
name="start_date"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
End Date (Optional)
|
||||
</label>
|
||||
<input type="date"
|
||||
name="end_date"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea name="description"
|
||||
rows="2"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Site Coordinates (Optional)
|
||||
Site Address
|
||||
</label>
|
||||
<input type="text"
|
||||
name="site_address"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Site Coordinates
|
||||
<span class="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
name="site_coordinates"
|
||||
placeholder="40.7128,-74.0060"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button type="button"
|
||||
onclick="hideCreateProjectModal()"
|
||||
class="px-6 py-2 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 type="submit"
|
||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||
Create Project
|
||||
</button>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Start Date</label>
|
||||
<input type="date" name="start_date"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">End Date <span class="text-gray-400 font-normal">(optional)</span></label>
|
||||
<input type="date" name="end_date"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Modules -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Add Modules
|
||||
<span class="text-gray-400 font-normal">(optional — can be added later)</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex items-center gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-orange-400 has-[:checked]:border-orange-400 has-[:checked]:bg-orange-50 dark:has-[:checked]:bg-orange-900/20 transition-colors">
|
||||
<input type="checkbox" id="ov-module-sound" class="accent-seismo-orange">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Sound Monitoring</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">SLMs, sessions, reports</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-blue-400 has-[:checked]:border-blue-400 has-[:checked]:bg-blue-50 dark:has-[:checked]:bg-blue-900/20 transition-colors">
|
||||
<input type="checkbox" id="ov-module-vibration" class="accent-blue-500">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Vibration Monitoring</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Seismographs, modems</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ov-create-error" class="hidden mt-3 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm"></div>
|
||||
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button type="button"
|
||||
onclick="hideCreateProjectModal()"
|
||||
class="px-6 py-2 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 type="submit" id="ov-submit-btn"
|
||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium transition-colors">
|
||||
Create Project
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,31 +239,58 @@ function showCreateProjectModal() {
|
||||
document.getElementById('createProjectModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showCreateProjectModal() {
|
||||
document.getElementById('createProjectModal').classList.remove('hidden');
|
||||
document.getElementById('createProjectFormElement').reset();
|
||||
document.getElementById('ov-create-error').classList.add('hidden');
|
||||
}
|
||||
|
||||
function hideCreateProjectModal() {
|
||||
document.getElementById('createProjectModal').classList.add('hidden');
|
||||
document.getElementById('projectTypeSelection').classList.remove('hidden');
|
||||
document.getElementById('projectDetailsForm').classList.add('hidden');
|
||||
}
|
||||
|
||||
function selectProjectType(typeId, typeName) {
|
||||
document.getElementById('project_type_id').value = typeId;
|
||||
document.getElementById('projectTypeSelection').classList.add('hidden');
|
||||
document.getElementById('projectDetailsForm').classList.remove('hidden');
|
||||
}
|
||||
document.getElementById('createProjectFormElement').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const submitBtn = document.getElementById('ov-submit-btn');
|
||||
const errorDiv = document.getElementById('ov-create-error');
|
||||
errorDiv.classList.add('hidden');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Creating...';
|
||||
|
||||
function backToTypeSelection() {
|
||||
document.getElementById('projectTypeSelection').classList.remove('hidden');
|
||||
document.getElementById('projectDetailsForm').classList.add('hidden');
|
||||
}
|
||||
const formData = new FormData(this);
|
||||
// project_type_id no longer required — send empty string so backend accepts it
|
||||
formData.set('project_type_id', '');
|
||||
|
||||
// Handle form submission success
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.elt.id === 'createProjectFormElement' && event.detail.successful) {
|
||||
try {
|
||||
const resp = await fetch('/api/projects/create', { method: 'POST', body: formData });
|
||||
const result = await resp.json();
|
||||
if (!resp.ok || !result.success) {
|
||||
errorDiv.textContent = result.detail || result.message || 'Failed to create project';
|
||||
errorDiv.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
const projectId = result.project_id;
|
||||
// Add selected modules
|
||||
if (document.getElementById('ov-module-sound').checked) {
|
||||
await fetch(`/api/projects/${projectId}/modules`, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ module_type: 'sound_monitoring' }),
|
||||
});
|
||||
}
|
||||
if (document.getElementById('ov-module-vibration').checked) {
|
||||
await fetch(`/api/projects/${projectId}/modules`, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ module_type: 'vibration_monitoring' }),
|
||||
});
|
||||
}
|
||||
hideCreateProjectModal();
|
||||
// Refresh project list
|
||||
htmx.ajax('GET', '/api/projects/list', {target: '#projects-list'});
|
||||
// Show success message
|
||||
alert('Project created successfully!');
|
||||
} catch(err) {
|
||||
errorDiv.textContent = `Error: ${err.message}`;
|
||||
errorDiv.classList.remove('hidden');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Create Project';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
+61
-29
@@ -181,6 +181,27 @@ let chartInstance = null;
|
||||
let allData = [];
|
||||
let allHeaders = [];
|
||||
|
||||
// Session period window (null = no filtering)
|
||||
const SESSION_PERIOD_START_HOUR = {{ period_start_hour if period_start_hour is not none else 'null' }};
|
||||
const SESSION_PERIOD_END_HOUR = {{ period_end_hour if period_end_hour is not none else 'null' }};
|
||||
|
||||
/**
|
||||
* Returns true if the given hour integer is within the session's period window.
|
||||
* Always returns true when no period window is configured.
|
||||
*/
|
||||
function _isInPeriodWindow(hour) {
|
||||
if (SESSION_PERIOD_START_HOUR === null || SESSION_PERIOD_END_HOUR === null) return true;
|
||||
const sh = SESSION_PERIOD_START_HOUR;
|
||||
const eh = SESSION_PERIOD_END_HOUR;
|
||||
if (eh > sh) {
|
||||
// Same-day window, e.g. 7–19
|
||||
return hour >= sh && hour < eh;
|
||||
} else {
|
||||
// Crosses midnight, e.g. 19–7
|
||||
return hour >= sh || hour < eh;
|
||||
}
|
||||
}
|
||||
|
||||
// Load data on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadRndData();
|
||||
@@ -387,19 +408,21 @@ function renderChart(data, fileType) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(headers, data) {
|
||||
const headerRow = document.getElementById('table-header');
|
||||
const tbody = document.getElementById('table-body');
|
||||
function _rowHour(row) {
|
||||
// Parse hour from "Start Time" field (format: "YYYY/MM/DD HH:MM:SS")
|
||||
const t = row['Start Time'];
|
||||
if (!t) return null;
|
||||
const parts = t.split(' ');
|
||||
if (parts.length < 2) return null;
|
||||
return parseInt(parts[1].split(':')[0], 10);
|
||||
}
|
||||
|
||||
// Render headers
|
||||
headerRow.innerHTML = '<tr>' + headers.map(h =>
|
||||
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}</th>`
|
||||
).join('') + '</tr>';
|
||||
|
||||
// Render rows (limit to first 500 for performance)
|
||||
const displayData = data.slice(0, 500);
|
||||
tbody.innerHTML = displayData.map(row =>
|
||||
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
|
||||
function _buildRow(headers, row) {
|
||||
const hour = _rowHour(row);
|
||||
const inWindow = hour === null || _isInPeriodWindow(hour);
|
||||
const dimClass = inWindow ? '' : 'opacity-40';
|
||||
const titleAttr = (!inWindow) ? ' title="Outside period window"' : '';
|
||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 ${dimClass}"${titleAttr}>` +
|
||||
headers.map(h => {
|
||||
const val = row[h];
|
||||
let displayVal = val;
|
||||
@@ -410,12 +433,34 @@ function renderTable(headers, data) {
|
||||
}
|
||||
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
||||
}).join('') +
|
||||
'</tr>'
|
||||
).join('');
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function renderTable(headers, data) {
|
||||
const headerRow = document.getElementById('table-header');
|
||||
const tbody = document.getElementById('table-body');
|
||||
|
||||
// Render headers — add period window indicator if configured
|
||||
let periodNote = '';
|
||||
if (SESSION_PERIOD_START_HOUR !== null && SESSION_PERIOD_END_HOUR !== null) {
|
||||
function _fmtH(h) { const ampm = h < 12 ? 'AM' : 'PM'; return `${h%12||12}:00 ${ampm}`; }
|
||||
periodNote = ` <span class="ml-2 text-indigo-500 dark:text-indigo-400 font-normal normal-case text-xs" title="Dimmed rows are outside this window">Period: ${_fmtH(SESSION_PERIOD_START_HOUR)} → ${_fmtH(SESSION_PERIOD_END_HOUR)}</span>`;
|
||||
}
|
||||
headerRow.innerHTML = '<tr>' + headers.map((h, i) =>
|
||||
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}${i === 0 ? periodNote : ''}</th>`
|
||||
).join('') + '</tr>';
|
||||
|
||||
// Render rows (limit to first 500 for performance)
|
||||
const displayData = data.slice(0, 500);
|
||||
tbody.innerHTML = displayData.map(row => _buildRow(headers, row)).join('');
|
||||
|
||||
// Update row count
|
||||
const inWindowCount = data.filter(r => { const h = _rowHour(r); return h === null || _isInPeriodWindow(h); }).length;
|
||||
const windowNote = (SESSION_PERIOD_START_HOUR !== null && inWindowCount < data.length)
|
||||
? ` (${inWindowCount} in period window)`
|
||||
: '';
|
||||
document.getElementById('row-count').textContent =
|
||||
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows` : `${data.length.toLocaleString()} rows`;
|
||||
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows${windowNote}` : `${data.length.toLocaleString()} rows${windowNote}`;
|
||||
|
||||
// Search functionality
|
||||
document.getElementById('table-search').addEventListener('input', function(e) {
|
||||
@@ -427,20 +472,7 @@ function renderTable(headers, data) {
|
||||
);
|
||||
|
||||
const displayFiltered = filtered.slice(0, 500);
|
||||
tbody.innerHTML = displayFiltered.map(row =>
|
||||
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
|
||||
headers.map(h => {
|
||||
const val = row[h];
|
||||
let displayVal = val;
|
||||
if (val === null || val === undefined) {
|
||||
displayVal = '-';
|
||||
} else if (typeof val === 'number') {
|
||||
displayVal = val.toFixed(1);
|
||||
}
|
||||
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
||||
}).join('') +
|
||||
'</tr>'
|
||||
).join('');
|
||||
tbody.innerHTML = displayFiltered.map(row => _buildRow(headers, row)).join('');
|
||||
|
||||
document.getElementById('row-count').textContent =
|
||||
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;
|
||||
|
||||
+121
-8
@@ -3,6 +3,7 @@
|
||||
{% block title %}Devices - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "partials/fleet_tab_strip.html" %}
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
@@ -66,6 +67,7 @@
|
||||
<button class="filter-btn filter-status active-filter" data-value="all">All</button>
|
||||
<button class="filter-btn filter-status" data-value="deployed">Deployed</button>
|
||||
<button class="filter-btn filter-status" data-value="benched">Benched</button>
|
||||
<button class="filter-btn filter-status" data-value="allocated">Allocated</button>
|
||||
<button class="filter-btn filter-status" data-value="retired">Retired</button>
|
||||
<button class="filter-btn filter-status" data-value="ignored">Ignored</button>
|
||||
</div>
|
||||
@@ -1230,12 +1232,22 @@
|
||||
|
||||
// Refresh device list (applies current client-side filters after load)
|
||||
function refreshDeviceList() {
|
||||
const scrollY = window.scrollY;
|
||||
htmx.ajax('GET', '/partials/devices-all', {
|
||||
target: '#device-content',
|
||||
swap: 'innerHTML'
|
||||
}).then(() => {
|
||||
// Re-apply filters after content loads
|
||||
setTimeout(filterDevices, 100);
|
||||
setTimeout(() => {
|
||||
filterDevices();
|
||||
// Re-apply sort if one was active
|
||||
if (window.currentSort && window.currentSort.column) {
|
||||
// sortTable toggles direction, so pre-flip so the toggle lands on the correct value
|
||||
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
sortTable(window.currentSort.column);
|
||||
}
|
||||
// Restore scroll position
|
||||
window.scrollTo(0, scrollY);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1248,13 +1260,114 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Silently refresh only the status/age/last-seen fields in the existing rows
|
||||
// without touching the DOM structure, so sort/scroll/filters are undisturbed.
|
||||
async function refreshStatusInPlace() {
|
||||
let snapshot;
|
||||
try {
|
||||
const resp = await fetch('/api/roster/status-snapshot');
|
||||
if (!resp.ok) return;
|
||||
snapshot = await resp.json();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
const units = snapshot.units || {};
|
||||
|
||||
// Helper: map status string → dot color class
|
||||
function statusDotClass(status) {
|
||||
if (status === 'OK') return 'bg-green-500';
|
||||
if (status === 'Pending') return 'bg-yellow-500';
|
||||
if (status === 'Missing') return 'bg-red-500';
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
|
||||
// --- Desktop table rows ---
|
||||
document.querySelectorAll('#roster-tbody tr[data-id]').forEach(row => {
|
||||
const uid = row.dataset.id;
|
||||
const u = units[uid];
|
||||
if (!u) return;
|
||||
|
||||
const newStatus = u.status || 'Missing';
|
||||
const newAge = u.age || 'N/A';
|
||||
const newLast = u.last || '';
|
||||
|
||||
// Update data attributes used by sort/filter
|
||||
row.dataset.health = newStatus;
|
||||
row.dataset.age = newAge;
|
||||
row.dataset.lastSeen = newLast;
|
||||
|
||||
// Status dot (first span inside first td)
|
||||
const dot = row.querySelector('td:first-child span:first-child');
|
||||
if (dot && row.dataset.status === 'deployed') {
|
||||
dot.className = `w-3 h-3 rounded-full ${statusDotClass(newStatus)}`;
|
||||
dot.title = newStatus;
|
||||
}
|
||||
|
||||
// Age cell (6th td — index 5)
|
||||
const cells = row.querySelectorAll('td');
|
||||
if (cells[5]) {
|
||||
const ageDiv = cells[5].querySelector('div');
|
||||
if (ageDiv) {
|
||||
ageDiv.textContent = newAge;
|
||||
ageDiv.className = `text-sm ${
|
||||
newStatus === 'Missing' ? 'text-red-600 dark:text-red-400 font-semibold' :
|
||||
newStatus === 'Pending' ? 'text-yellow-600 dark:text-yellow-400' :
|
||||
'text-gray-500 dark:text-gray-400'
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Last-seen cell (5th td — index 4)
|
||||
if (cells[4]) {
|
||||
const lsDiv = cells[4].querySelector('.last-seen-cell');
|
||||
if (lsDiv) {
|
||||
lsDiv.dataset.iso = newLast;
|
||||
lsDiv.textContent = newLast;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Mobile cards ---
|
||||
document.querySelectorAll('.device-card[data-unit-id]').forEach(card => {
|
||||
const uid = card.dataset.unitId;
|
||||
const u = units[uid];
|
||||
if (!u) return;
|
||||
|
||||
const newStatus = u.status || 'Missing';
|
||||
const newAge = u.age || 'N/A';
|
||||
|
||||
card.dataset.health = newStatus;
|
||||
card.dataset.age = newAge;
|
||||
|
||||
// Status dot (first span in header div)
|
||||
const dot = card.querySelector('span.rounded-full:first-child');
|
||||
if (dot && card.dataset.status === 'deployed') {
|
||||
dot.className = `w-4 h-4 rounded-full ${statusDotClass(newStatus)}`;
|
||||
dot.title = newStatus;
|
||||
}
|
||||
|
||||
// Age text — the div containing the clock emoji
|
||||
card.querySelectorAll('.text-sm').forEach(div => {
|
||||
if (div.textContent.includes('🕐')) {
|
||||
div.textContent = `🕐 ${newAge}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update the "last updated" timestamp
|
||||
const ts = document.getElementById('last-updated');
|
||||
if (ts) ts.textContent = new Date().toLocaleTimeString();
|
||||
|
||||
// Re-apply active filters (sort order is untouched since DOM rows weren't moved)
|
||||
filterDevices();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker)
|
||||
// Poll status every 30 seconds — updates cells in-place, no DOM restructuring
|
||||
setInterval(() => {
|
||||
const deviceContent = document.getElementById('device-content');
|
||||
if (deviceContent && !isAnyModalOpen()) {
|
||||
// Only auto-refresh if no modal is open
|
||||
refreshDeviceList();
|
||||
if (!isAnyModalOpen()) {
|
||||
refreshStatusInPlace();
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -1352,7 +1465,7 @@
|
||||
|
||||
// Toggle health filter visibility (hide for retired/ignored)
|
||||
const healthGroup = document.getElementById('health-filter-group');
|
||||
if (this.dataset.value === 'retired' || this.dataset.value === 'ignored') {
|
||||
if (this.dataset.value === 'retired' || this.dataset.value === 'ignored' || this.dataset.value === 'allocated') {
|
||||
healthGroup.style.display = 'none';
|
||||
} else {
|
||||
healthGroup.style.display = 'flex';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}Seismographs - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "partials/fleet_tab_strip.html" %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Seismographs</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage and monitor seismograph units</p>
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ session.session_label or 'Session' }} — {{ project.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
<a href="/projects" class="hover:text-seismo-orange">Projects</a>
|
||||
<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="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
<a href="/projects/{{ project_id }}" class="hover:text-seismo-orange">{{ project.name }}</a>
|
||||
<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="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-900 dark:text-white truncate max-w-xs">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
|
||||
</nav>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<svg class="w-7 h-7 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
<span id="header-label">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
|
||||
</h1>
|
||||
{% if location %}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ location.name }}{% if unit %} · {{ unit.id }}{% endif %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{% if session.status == 'completed' %}
|
||||
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Completed</span>
|
||||
{% elif session.status == 'recording' %}
|
||||
<span class="px-3 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span> Recording
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- LEFT COLUMN: Info + Edit -->
|
||||
<div class="lg:col-span-1 space-y-4">
|
||||
|
||||
<!-- Session Info Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Session Info</h2>
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Label</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white text-right max-w-[180px] truncate"
|
||||
id="info-label">{{ session.session_label or '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ location.name if location else '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Period</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white" id="info-period">
|
||||
{% set PLABELS = {'weekday_day':'Weekday Day','weekday_night':'Weekday Night','weekend_day':'Weekend Day','weekend_night':'Weekend Night'} %}
|
||||
{{ PLABELS.get(session.period_type, '—') }}
|
||||
</dd>
|
||||
</div>
|
||||
{% if effective_range %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
|
||||
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective">{{ effective_range }}</dd>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex justify-between hidden" id="info-effective-row">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
|
||||
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective"></dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Report Date</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white" id="info-report-date">
|
||||
{{ report_date or '— (auto)' }}
|
||||
</dd>
|
||||
</div>
|
||||
{% if session.started_at %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Started</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.started_at|local_datetime }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.stopped_at %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Ended</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.stopped_at|local_datetime }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.duration_seconds %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Duration</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ session.duration_seconds // 3600 }}h {{ (session.duration_seconds % 3600) // 60 }}m</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.device_model %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Device Model</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ session.device_model }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session_meta.get('store_name') %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Store Name</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.store_name }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session_meta.get('serial_number') %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Serial #</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.serial_number }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Edit Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-4">Edit Session</h2>
|
||||
<form id="edit-form" onsubmit="saveSession(event)">
|
||||
|
||||
<!-- Label -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Label</label>
|
||||
<input type="text" id="edit-label" name="session_label"
|
||||
value="{{ session.session_label or '' }}"
|
||||
placeholder="e.g. NRL-1 — Mon 3/24 — Night"
|
||||
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
</div>
|
||||
|
||||
<!-- Section: Required Recording Window -->
|
||||
<div class="mb-4 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-100 dark:border-indigo-800">
|
||||
<p class="text-xs font-semibold text-indigo-700 dark:text-indigo-300 mb-0.5">Required Recording Window</p>
|
||||
<p class="text-xs text-indigo-500 dark:text-indigo-400 mb-3">The hours that count for reports. Only data within this window is included.</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Period Type</label>
|
||||
<select id="edit-period-type" name="period_type"
|
||||
onchange="fillPeriodDefaults()"
|
||||
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
<option value="">— Not Set —</option>
|
||||
<option value="weekday_day" {% if session.period_type == 'weekday_day' %}selected{% endif %}>Weekday Day</option>
|
||||
<option value="weekday_night" {% if session.period_type == 'weekday_night' %}selected{% endif %}>Weekday Night</option>
|
||||
<option value="weekend_day" {% if session.period_type == 'weekend_day' %}selected{% endif %}>Weekend Day</option>
|
||||
<option value="weekend_night" {% if session.period_type == 'weekend_night' %}selected{% endif %}>Weekend Night</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From (hour)</label>
|
||||
<div class="relative">
|
||||
<input type="number" min="0" max="23" id="edit-start-hour" name="period_start_hour"
|
||||
value="{{ session.period_start_hour if session.period_start_hour is not none else '' }}"
|
||||
placeholder="e.g. 19"
|
||||
oninput="updateWindowPreview()"
|
||||
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">To (hour)</label>
|
||||
<input type="number" min="0" max="23" id="edit-end-hour" name="period_end_hour"
|
||||
value="{{ session.period_end_hour if session.period_end_hour is not none else '' }}"
|
||||
placeholder="e.g. 7"
|
||||
oninput="updateWindowPreview()"
|
||||
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live preview -->
|
||||
<div id="window-preview" class="text-xs font-medium text-indigo-600 dark:text-indigo-300 min-h-[1rem]">
|
||||
{% if session.period_start_hour is not none and session.period_end_hour is not none %}
|
||||
{% set sh = session.period_start_hour %}
|
||||
{% set eh = session.period_end_hour %}
|
||||
Window: {{ (sh % 12) or 12 }}:00 {{ 'AM' if sh < 12 else 'PM' }} → {{ (eh % 12) or 12 }}:00 {{ 'AM' if eh < 12 else 'PM' }}{% if eh <= sh %} (crosses midnight){% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Target Date <span class="text-gray-400">(optional — day sessions only)</span>
|
||||
</label>
|
||||
<input type="date" id="edit-report-date" name="report_date"
|
||||
value="{{ report_date or '' }}"
|
||||
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Leave blank to auto-select the last day with data in the window.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Device On/Off Times -->
|
||||
<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-0.5">Device On/Off Times</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mb-3">When the meter was actually running. Usually set automatically from the data file.</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered on</label>
|
||||
<input type="datetime-local" id="edit-started-at" name="started_at"
|
||||
value="{{ session.started_at|local_datetime_input if session.started_at else '' }}"
|
||||
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered off</label>
|
||||
<input type="datetime-local" id="edit-stopped-at" name="stopped_at"
|
||||
value="{{ session.stopped_at|local_datetime_input if session.stopped_at else '' }}"
|
||||
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button type="submit"
|
||||
class="flex-1 text-sm py-2 bg-seismo-orange text-white rounded-lg hover:bg-orange-600 transition-colors font-medium">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
<div id="save-status" class="hidden text-xs text-center pt-2"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Files + Report Actions -->
|
||||
<div class="lg:col-span-2 space-y-5">
|
||||
|
||||
<!-- Files List -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Data Files
|
||||
<span class="ml-2 text-xs font-normal text-gray-400">({{ files|length }})</span>
|
||||
</h2>
|
||||
</div>
|
||||
{% if files %}
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{% for f in files %}
|
||||
{% set fname = f.file_path.split('/')[-1] %}
|
||||
{% set is_rnd = fname.lower().endswith('.rnd') %}
|
||||
{% set is_leq = '_leq_' in fname.lower() or fname.lower().startswith('au2_') %}
|
||||
<div class="flex items-center gap-3 px-5 py-3 hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<!-- Icon -->
|
||||
<div class="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center
|
||||
{% if is_rnd %}bg-green-100 dark:bg-green-900/30{% else %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
{% if is_rnd %}
|
||||
<svg class="w-4 h-4 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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Name + meta -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ fname }}</div>
|
||||
<div class="text-xs text-gray-400 flex items-center gap-2 mt-0.5">
|
||||
<span>{{ f.file_type | upper }}</span>
|
||||
{% if f.file_size_bytes %}
|
||||
<span>{{ (f.file_size_bytes / 1024) | round(1) }} KB</span>
|
||||
{% endif %}
|
||||
{% if is_leq %}<span class="text-green-600 dark:text-green-400 font-medium">Leq</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{% if is_rnd %}
|
||||
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/view-rnd"
|
||||
class="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors">
|
||||
View
|
||||
</a>
|
||||
{% if is_leq %}
|
||||
<button onclick="openSingleFileReport('{{ f.id }}', '{{ fname }}')"
|
||||
class="px-2 py-1 text-xs bg-emerald-600 text-white rounded hover:bg-emerald-700 transition-colors">
|
||||
Report
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/download"
|
||||
class="px-2 py-1 text-xs border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-5 py-10 text-center text-gray-400">
|
||||
<p>No files found for this session.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Report Actions -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Report Actions</h2>
|
||||
|
||||
{% if session.status == 'completed' %}
|
||||
{% set has_rnd = files | selectattr('file_type', 'equalto', 'rnd') | list | length > 0 %}
|
||||
{% if has_rnd %}
|
||||
<div class="p-3 bg-gray-50 dark:bg-slate-700/50 rounded-lg space-y-2">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Use the <strong>Combined Report Wizard</strong> to generate an Excel report for this session, or click <strong>View</strong> on a Leq file above to access per-file reporting.
|
||||
{% if session.period_start_hour is not none %}
|
||||
<br><span class="text-indigo-600 dark:text-indigo-400">Period window {{ session.period_start_hour }}:00–{{ session.period_end_hour }}:00 will be applied.</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="/projects/{{ project_id }}?tab=data"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
|
||||
<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="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Go to Combined Report Wizard
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">No .rnd files found — upload data to generate a report.</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">Reports are available after the session is completed.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PROJECT_ID = '{{ project_id }}';
|
||||
const SESSION_ID = '{{ session.id }}';
|
||||
|
||||
const PERIOD_DEFAULT_HOURS = {
|
||||
weekday_day: {start: 7, end: 19},
|
||||
weekday_night: {start: 19, end: 7},
|
||||
weekend_day: {start: 7, end: 19},
|
||||
weekend_night: {start: 19, end: 7},
|
||||
};
|
||||
|
||||
function fillPeriodDefaults() {
|
||||
const pt = document.getElementById('edit-period-type').value;
|
||||
const defaults = PERIOD_DEFAULT_HOURS[pt];
|
||||
if (defaults) {
|
||||
document.getElementById('edit-start-hour').value = defaults.start;
|
||||
document.getElementById('edit-end-hour').value = defaults.end;
|
||||
}
|
||||
updateWindowPreview();
|
||||
}
|
||||
|
||||
function updateWindowPreview() {
|
||||
const sh = parseInt(document.getElementById('edit-start-hour').value, 10);
|
||||
const eh = parseInt(document.getElementById('edit-end-hour').value, 10);
|
||||
const el = document.getElementById('window-preview');
|
||||
if (!el) return;
|
||||
if (isNaN(sh) || isNaN(eh)) { el.textContent = ''; return; }
|
||||
function fmt(h) { return `${h % 12 || 12}:00 ${h < 12 ? 'AM' : 'PM'}`; }
|
||||
const crosses = eh <= sh;
|
||||
el.textContent = `Window: ${fmt(sh)} → ${fmt(eh)}${crosses ? ' (crosses midnight)' : ''}`;
|
||||
}
|
||||
|
||||
// Run once on load to populate preview if values already set
|
||||
document.addEventListener('DOMContentLoaded', updateWindowPreview);
|
||||
|
||||
async function saveSession(e) {
|
||||
e.preventDefault();
|
||||
const status = document.getElementById('save-status');
|
||||
status.className = 'text-xs text-center pt-1 text-gray-400';
|
||||
status.textContent = 'Saving…';
|
||||
status.classList.remove('hidden');
|
||||
|
||||
const form = document.getElementById('edit-form');
|
||||
const payload = {};
|
||||
|
||||
const label = form.session_label.value.trim();
|
||||
payload.session_label = label || null;
|
||||
|
||||
const pt = form.period_type.value;
|
||||
payload.period_type = pt || null;
|
||||
|
||||
const sh = form.period_start_hour.value;
|
||||
const eh = form.period_end_hour.value;
|
||||
payload.period_start_hour = sh !== '' ? parseInt(sh, 10) : null;
|
||||
payload.period_end_hour = eh !== '' ? parseInt(eh, 10) : null;
|
||||
|
||||
const rd = form.report_date.value;
|
||||
payload.report_date = rd || null;
|
||||
|
||||
const sa = form.started_at.value;
|
||||
if (sa) payload.started_at = sa;
|
||||
|
||||
const st = form.stopped_at.value;
|
||||
if (st) payload.stopped_at = st;
|
||||
else if (form.stopped_at.value === '') payload.stopped_at = null;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${SESSION_ID}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
const result = await resp.json();
|
||||
|
||||
// Update displayed label
|
||||
const newLabel = result.session_label || ('Session ' + SESSION_ID.slice(0, 8) + '…');
|
||||
document.getElementById('header-label').textContent = newLabel;
|
||||
document.getElementById('info-label').textContent = result.session_label || '—';
|
||||
document.getElementById('info-period').textContent = {
|
||||
weekday_day: 'Weekday Day', weekday_night: 'Weekday Night',
|
||||
weekend_day: 'Weekend Day', weekend_night: 'Weekend Night'
|
||||
}[result.period_type] || '—';
|
||||
document.getElementById('info-report-date').textContent = result.report_date || '— (auto)';
|
||||
|
||||
status.className = 'text-xs text-center pt-1 text-green-600 dark:text-green-400';
|
||||
status.textContent = 'Saved!';
|
||||
setTimeout(() => status.classList.add('hidden'), 2500);
|
||||
} catch(err) {
|
||||
status.className = 'text-xs text-center pt-1 text-red-500';
|
||||
status.textContent = 'Error: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function openSingleFileReport(fileId, filename) {
|
||||
window.location.href = `/api/projects/${PROJECT_ID}/files/${fileId}/view-rnd`;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
+88
-1
@@ -355,6 +355,25 @@
|
||||
</form>
|
||||
<div id="uploadResult" class="hidden mt-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Deleted Projects -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Deleted Projects</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Projects that have been soft-deleted. Restore them or permanently remove them.</p>
|
||||
</div>
|
||||
<button onclick="loadDeletedProjects()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex items-center gap-2">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="deletedProjectsList">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 italic">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -541,6 +560,12 @@
|
||||
Open
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# SFM Admin moved back to main nav as "Events" — see sidebar. #}
|
||||
|
||||
{# Metadata Backfill + Project Tidy moved to Tools (they're
|
||||
operator workflows, not admin/dev surfaces). Find them
|
||||
at /tools. #}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -584,6 +609,67 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// ========== DELETED PROJECTS ==========
|
||||
|
||||
async function loadDeletedProjects() {
|
||||
const container = document.getElementById('deletedProjectsList');
|
||||
container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 italic">Loading...</p>';
|
||||
try {
|
||||
const resp = await fetch('/api/projects/deleted');
|
||||
const projects = await resp.json();
|
||||
if (!projects.length) {
|
||||
container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 italic">No deleted projects.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
${projects.map(p => `
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900/30">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900 dark:text-white">${p.name}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
${p.client_name ? p.client_name + ' · ' : ''}Deleted ${new Date(p.deleted_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<button onclick="restoreProject('${p.id}', '${p.name.replace(/'/g, "\\'")}')"
|
||||
class="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||
Restore
|
||||
</button>
|
||||
<button onclick="permanentlyDeleteProject('${p.id}', '${p.name.replace(/'/g, "\\'")}')"
|
||||
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||
Delete Permanently
|
||||
</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
container.innerHTML = '<p class="text-sm text-red-500">Failed to load deleted projects.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreProject(projectId, name) {
|
||||
if (!confirm(`Restore "${name}"?`)) return;
|
||||
const resp = await fetch(`/api/projects/${projectId}/restore`, { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
loadDeletedProjects();
|
||||
} else {
|
||||
const d = await resp.json();
|
||||
alert('Failed to restore: ' + (d.detail || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
|
||||
async function permanentlyDeleteProject(projectId, name) {
|
||||
if (!confirm(`Permanently delete "${name}" and all its data? This cannot be undone.`)) return;
|
||||
const resp = await fetch(`/api/projects/${projectId}/permanent`, { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
loadDeletedProjects();
|
||||
} else {
|
||||
const d = await resp.json();
|
||||
alert('Failed to delete: ' + (d.detail || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
|
||||
// ========== TAB MANAGEMENT ==========
|
||||
|
||||
function showTab(tabName) {
|
||||
@@ -609,9 +695,10 @@ function showTab(tabName) {
|
||||
// Save last active tab to localStorage
|
||||
localStorage.setItem('settings-last-tab', tabName);
|
||||
|
||||
// Load roster table when data tab is shown
|
||||
// Load roster table and deleted projects when data tab is shown
|
||||
if (tabName === 'data') {
|
||||
loadRosterTable();
|
||||
loadDeletedProjects();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}SFM Event Data - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Events</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet-wide event database. Filter by serial, date, false-trigger, or browse the units roster.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="sfm-status-badge" class="px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
Checking SFM…
|
||||
</span>
|
||||
<button onclick="checkSFMHealth()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div id="sfm-stats" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Known Units</span>
|
||||
<span id="stat-units" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
|
||||
<span id="stat-events" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab navigation -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="flex overflow-x-auto" aria-label="Tabs">
|
||||
<button onclick="switchTab('events')" id="tab-events"
|
||||
class="sfm-tab active-tab shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors">
|
||||
Events
|
||||
</button>
|
||||
<button onclick="switchTab('units')" id="tab-units"
|
||||
class="sfm-tab shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors">
|
||||
Units
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ── Events Tab ─────────────────────────────────────────────────────── -->
|
||||
<div id="panel-events" class="sfm-panel p-6">
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Unit Serial</label>
|
||||
<select id="ev-serial" onchange="loadEvents()"
|
||||
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 Units</option>
|
||||
</select>
|
||||
</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="ev-from" onchange="loadEvents()"
|
||||
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="ev-to" onchange="loadEvents()"
|
||||
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="ev-false-trigger" onchange="loadEvents()"
|
||||
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 Events</option>
|
||||
<option value="false">Real Events Only</option>
|
||||
<option value="true">False Triggers 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="ev-limit" onchange="loadEvents()"
|
||||
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="250">250</option>
|
||||
<option value="500" selected>500</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="clearEventFilters()"
|
||||
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||
Clear Filters
|
||||
</button>
|
||||
<button onclick="loadEvents()"
|
||||
class="ml-auto px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-orange-600">
|
||||
↻ Reload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Events table -->
|
||||
<div id="events-container" class="overflow-x-auto">
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>
|
||||
Loading events…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Units Tab ─────────────────────────────────────────────────────── -->
|
||||
<div id="panel-units" class="sfm-panel hidden p-6">
|
||||
<div id="units-container">
|
||||
<div class="text-center py-12 text-gray-500">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>
|
||||
Loading units…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- end tab container -->
|
||||
|
||||
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
||||
{% include 'partials/event_detail_modal.html' %}
|
||||
<script src="/static/event-modal.js"></script>
|
||||
|
||||
<style>
|
||||
.sfm-tab {
|
||||
color: #6b7280;
|
||||
border-color: transparent;
|
||||
}
|
||||
.sfm-tab:hover {
|
||||
color: #374151;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
.dark .sfm-tab {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.dark .sfm-tab:hover {
|
||||
color: #f3f4f6;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
.sfm-tab.active-tab {
|
||||
color: #f48b1c;
|
||||
border-color: #f48b1c;
|
||||
}
|
||||
.dark .sfm-tab.active-tab {
|
||||
color: #f48b1c;
|
||||
border-color: #f48b1c;
|
||||
}
|
||||
.sfm-panel { display: block; }
|
||||
.sfm-panel.hidden { display: none; }
|
||||
|
||||
/* PPV colour thresholds */
|
||||
.ppv-low { color: #10b981; }
|
||||
.ppv-mid { color: #f59e0b; }
|
||||
.ppv-high { color: #ef4444; font-weight: 600; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
let _knownSerials = [];
|
||||
|
||||
// ── Tabs ────────────────────────────────────────────────────────────────────
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.sfm-tab').forEach(t => t.classList.remove('active-tab'));
|
||||
document.querySelectorAll('.sfm-panel').forEach(p => p.classList.add('hidden'));
|
||||
document.getElementById('tab-' + name).classList.add('active-tab');
|
||||
document.getElementById('panel-' + name).classList.remove('hidden');
|
||||
|
||||
// Lazy-load tabs on first visit
|
||||
if (name === 'units' && document.getElementById('units-container').innerHTML.includes('Loading')) loadUnits();
|
||||
}
|
||||
|
||||
// ── SFM health ───────────────────────────────────────────────────────────────
|
||||
async function checkSFMHealth() {
|
||||
const badge = document.getElementById('sfm-status-badge');
|
||||
badge.textContent = 'Checking…';
|
||||
badge.className = 'px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||
try {
|
||||
const r = await fetch('/api/sfm/health');
|
||||
const d = await r.json();
|
||||
if (d.sfm_status === 'connected') {
|
||||
badge.textContent = '● SFM Connected';
|
||||
badge.className = 'px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
loadStats();
|
||||
} else {
|
||||
badge.textContent = '● SFM Offline';
|
||||
badge.className = 'px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||
}
|
||||
} catch (e) {
|
||||
badge.textContent = '● SFM Error';
|
||||
badge.className = 'px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats ────────────────────────────────────────────────────────────────────
|
||||
async function loadStats() {
|
||||
try {
|
||||
const r = await fetch('/api/sfm/db/units');
|
||||
if (!r.ok) return;
|
||||
const units = await r.json();
|
||||
_knownSerials = units.map(u => u.serial);
|
||||
|
||||
const totalEvents = units.reduce((s, u) => s + (u.total_events || 0), 0);
|
||||
|
||||
document.getElementById('stat-units').textContent = units.length;
|
||||
document.getElementById('stat-events').textContent = totalEvents.toLocaleString();
|
||||
|
||||
// Populate serial dropdowns
|
||||
['ev-serial'].forEach(id => {
|
||||
const sel = document.getElementById(id);
|
||||
const cur = sel.value;
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
_knownSerials.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s; opt.textContent = s;
|
||||
sel.add(opt);
|
||||
});
|
||||
if (cur) sel.value = cur;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to load stats:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events tab ───────────────────────────────────────────────────────────────
|
||||
async function loadEvents() {
|
||||
const container = document.getElementById('events-container');
|
||||
container.innerHTML = '<div class="text-center py-8 text-gray-500"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Loading events…</div>';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const serial = document.getElementById('ev-serial').value;
|
||||
const from = document.getElementById('ev-from').value;
|
||||
const to = document.getElementById('ev-to').value;
|
||||
const ft = document.getElementById('ev-false-trigger').value;
|
||||
const limit = document.getElementById('ev-limit').value;
|
||||
|
||||
if (serial) params.set('serial', serial);
|
||||
if (from) params.set('from_dt', from.replace('T', ' '));
|
||||
if (to) params.set('to_dt', to.replace('T', ' '));
|
||||
if (ft) params.set('false_trigger', ft);
|
||||
params.set('limit', limit);
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/sfm/db/events?' + params.toString());
|
||||
if (!r.ok) { throw new Error('HTTP ' + r.status); }
|
||||
const d = await r.json();
|
||||
renderEventsTable(d.events, d.count, container);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="text-center py-8 text-red-500">Failed to load events: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEventsTable(events, total, container) {
|
||||
if (!events || events.length === 0) {
|
||||
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><p class="text-sm">No events found matching the current filters.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = events.map(ev => {
|
||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||
const tran = fmtPPV(ev.tran_ppv);
|
||||
const vert = fmtPPV(ev.vert_ppv);
|
||||
const lng = fmtPPV(ev.long_ppv);
|
||||
const pvs = fmtPPV(ev.peak_vector_sum);
|
||||
const mic = ev.mic_ppv != null ? ev.mic_ppv.toFixed(3) : '—';
|
||||
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 dark:text-yellow-200">FT</span>` : '';
|
||||
const proj = ev.project ? `<span class="truncate max-w-[120px] inline-block" title="${esc(ev.project)}">${esc(ev.project)}</span>` : '<span class="text-gray-400">—</span>';
|
||||
|
||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer" onclick="showEventDetail('${esc(ev.id)}')">
|
||||
<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 font-medium text-seismo-orange">${esc(ev.serial)}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 max-w-[140px]">${proj}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.tran_ppv)}">${tran}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.vert_ppv)}">${vert}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.long_ppv)}">${lng}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${ppvClass(ev.peak_vector_sum)}">${pvs}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono text-gray-600 dark:text-gray-400">${mic}</td>
|
||||
<td class="px-4 py-2.5 text-sm">
|
||||
${ft}
|
||||
<button onclick="event.stopPropagation(); toggleFalseTrigger('${ev.id}', ${ev.false_trigger ? 'false' : 'true'}, this)"
|
||||
class="ml-1 px-2 py-0.5 rounded text-xs ${ev.false_trigger ? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300 hover:bg-yellow-100' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 hover:bg-yellow-50'}"
|
||||
title="${ev.false_trigger ? 'Clear false trigger' : 'Mark as false trigger'}">
|
||||
${ev.false_trigger ? '✕ FT' : 'Flag FT'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">Showing ${events.length} of ${total} events</div>
|
||||
<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-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Serial</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Project</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Mic</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function fmtPPV(v) {
|
||||
if (v == null) return '—';
|
||||
return v.toFixed(4);
|
||||
}
|
||||
|
||||
function ppvClass(v) {
|
||||
if (v == null) return 'text-gray-400';
|
||||
if (v < 0.5) return 'ppv-low';
|
||||
if (v < 2.0) return 'ppv-mid';
|
||||
return 'ppv-high';
|
||||
}
|
||||
|
||||
function clearEventFilters() {
|
||||
document.getElementById('ev-serial').value = '';
|
||||
document.getElementById('ev-from').value = '';
|
||||
document.getElementById('ev-to').value = '';
|
||||
document.getElementById('ev-false-trigger').value = '';
|
||||
document.getElementById('ev-limit').value = '500';
|
||||
loadEvents();
|
||||
}
|
||||
|
||||
async function toggleFalseTrigger(id, newValue, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
try {
|
||||
const r = await fetch(`/api/sfm/db/events/${id}/false_trigger?value=${newValue}`, { method: 'PATCH' });
|
||||
if (r.ok) {
|
||||
// Refresh the table after short delay
|
||||
setTimeout(loadEvents, 300);
|
||||
} else {
|
||||
btn.textContent = 'Error';
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = 'Error';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Event detail modal now lives in /static/event-modal.js (shared component).
|
||||
// `showEventDetail(eventId)` is exposed globally; row onclick handlers call it.
|
||||
|
||||
// ── Units tab ────────────────────────────────────────────────────────────────
|
||||
async function loadUnits() {
|
||||
const container = document.getElementById('units-container');
|
||||
try {
|
||||
const r = await fetch('/api/sfm/db/units');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const units = await r.json();
|
||||
|
||||
if (units.length === 0) {
|
||||
container.innerHTML = '<div class="text-center py-12 text-gray-500 text-sm">No units in database. Waiting for series3-watcher to forward events from Blastware ACH.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = units.map(u => {
|
||||
const lastSeen = u.last_seen ? u.last_seen.slice(0, 19).replace('T', ' ') : '—';
|
||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
||||
<td class="px-4 py-3 text-sm font-mono font-semibold text-seismo-orange">${esc(u.serial)}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">${lastSeen}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">${(u.total_events || 0).toLocaleString()}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<button onclick="filterEventsBySerial('${esc(u.serial)}')"
|
||||
class="px-3 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
View Events
|
||||
</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Serial</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Last Seen</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Events</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="text-center py-8 text-red-500 text-sm">Failed to load units: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterEventsBySerial(serial) {
|
||||
document.getElementById('ev-serial').value = serial;
|
||||
switchTab('events');
|
||||
loadEvents();
|
||||
}
|
||||
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkSFMHealth();
|
||||
loadEvents();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}Sound Level Meters - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "partials/fleet_tab_strip.html" %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Tools - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Tools</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Active operator workflows. Pair devices, clean up duplicates, generate reports.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
|
||||
<!-- 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">
|
||||
<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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Pair Devices</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Bidirectionally link seismographs ↔ modems (or SLMs ↔ modems) so they ship out together as a deployed pair.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Project Tidy -->
|
||||
<a href="/settings/developer/project-tidy"
|
||||
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-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-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="M19 11H5m14-4H5m14 8H5m14 4H5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Project Tidy</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Find duplicate-looking projects via fuzzy name match (typos, abbreviations) and bulk-merge them. Useful after a metadata backfill run.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Metadata Backfill -->
|
||||
<a href="/settings/developer/metadata-backfill"
|
||||
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-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-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="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Backfill from event metadata</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Auto-create projects, locations, and unit assignments from the operator-typed metadata baked into SFM events. Skip the manual entry.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Reports (per-project) -->
|
||||
<a href="/projects"
|
||||
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-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="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Reports</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Excel report generation lives on each project's detail page. Open a project and use <em>Generate Combined Report</em> (for multi-location sound studies) or single-location export.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Swap Detection (Phase 5c — coming soon) -->
|
||||
<div class="bg-gray-50 dark:bg-slate-800/50 rounded-xl shadow p-5 border border-dashed border-gray-300 dark:border-gray-700 cursor-not-allowed">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-400 flex items-center justify-center shrink-0">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="font-semibold text-gray-500 dark:text-gray-400">Swap Detection</h3>
|
||||
<span class="px-1.5 py-0.5 rounded text-xs bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400">soon</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">
|
||||
Daily background job that auto-detects unit swaps in the field (BE12345 → BE67890 at the same project + location) from operator-typed metadata. Coming in Phase 5c.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
+684
-11
@@ -278,14 +278,106 @@
|
||||
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
||||
</div>
|
||||
|
||||
<!-- Unit History Timeline -->
|
||||
<!-- Deployment Timeline (Phase 4 unified view — derived from
|
||||
unit_assignments + unit_history + SFM event overlay) -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
|
||||
<div id="historyTimeline" class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading history...</p>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment Timeline</h3>
|
||||
<button onclick="loadDeploymentTimeline()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="deploymentTimeline" class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SFM Events (seismographs only) -->
|
||||
<div id="sfmEventsSection" class="border-t border-gray-200 dark:border-gray-700 pt-6 hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">SFM Events</h3>
|
||||
<button onclick="loadUnitEvents()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI tiles -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
|
||||
<span id="ue-stat-total" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Unattributed</span>
|
||||
<span id="ue-stat-unattr" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">outside any assignment window</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Overall Peak</span>
|
||||
<span id="ue-stat-peak" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
<span id="ue-stat-peak-when" class="text-xs text-gray-500 dark:text-gray-400 mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
|
||||
<span id="ue-stat-last" class="text-base font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Bucket</label>
|
||||
<select id="ue-filter-bucket" onchange="loadUnitEvents()"
|
||||
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">All Events</option>
|
||||
<option value="attributed">Attributed Only</option>
|
||||
<option value="unattributed">Unattributed Only</option>
|
||||
</select>
|
||||
</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="ue-filter-from" onchange="loadUnitEvents()"
|
||||
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="ue-filter-to" onchange="loadUnitEvents()"
|
||||
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="ue-filter-ft" onchange="loadUnitEvents()"
|
||||
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="ue-filter-limit" onchange="loadUnitEvents()"
|
||||
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="250">250</option>
|
||||
<option value="500" selected>500</option>
|
||||
<option value="1000">1000</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="clearUnitEventFilters()"
|
||||
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>
|
||||
|
||||
<!-- Event table -->
|
||||
<div id="ue-events-container" class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">
|
||||
Loading events…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Photos -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
@@ -320,6 +412,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deployment Modal -->
|
||||
<div id="deploymentModal" class="hidden fixed inset-0 bg-black/50 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-lg">
|
||||
<div class="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 id="deploymentModalTitle" class="text-lg font-semibold text-gray-900 dark:text-white">Log Deployment</h3>
|
||||
<button onclick="closeDeploymentModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<svg class="w-6 h-6" 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"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<input type="hidden" id="deploymentModalId">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Deployed Date</label>
|
||||
<input type="date" id="deploymentDeployedDate" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Est. Removal Date</label>
|
||||
<input type="date" id="deploymentEstRemovalDate" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
</div>
|
||||
<div id="actualRemovalRow">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Actual Removal Date <span class="text-gray-400 font-normal">(fill when returned)</span></label>
|
||||
<input type="date" id="deploymentActualRemovalDate" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Job / Project</label>
|
||||
<input type="text" id="deploymentProjectRef" placeholder="e.g. Fay I-80, CMU Campus" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Location Name</label>
|
||||
<input type="text" id="deploymentLocationName" placeholder="e.g. North Gate, VP-001" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
|
||||
<textarea id="deploymentNotes" rows="2" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button onclick="closeDeploymentModal()" class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg text-sm transition-colors">Cancel</button>
|
||||
<button onclick="saveDeployment()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm transition-colors">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Mode: Unit Information Form (Hidden by default) -->
|
||||
<div id="editMode" class="hidden rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
@@ -499,7 +638,7 @@
|
||||
|
||||
<!-- Status Checkboxes -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 space-y-3">
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-6 flex-wrap">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="deployed" id="deployed" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
@@ -510,6 +649,18 @@
|
||||
class="w-4 h-4 text-purple-600 focus:ring-purple-500 rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Out for Calibration</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="allocated" id="allocated" value="true"
|
||||
onchange="document.getElementById('allocatedProjectRow').style.display = this.checked ? '' : 'none'"
|
||||
class="w-4 h-4 text-orange-500 focus:ring-orange-400 rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Allocated</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="allocatedProjectRow" style="display:none">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Allocated to Project</label>
|
||||
<input type="text" name="allocated_to_project_id" id="allocatedToProjectId"
|
||||
placeholder="Project name or ID"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-400 text-sm">
|
||||
</div>
|
||||
<!-- Hidden field for retired — controlled by the Retire button below -->
|
||||
<input type="hidden" name="retired" id="retired" value="">
|
||||
@@ -818,10 +969,14 @@ function populateViewMode() {
|
||||
|
||||
document.getElementById('age').textContent = unitStatus.age || '--';
|
||||
} else {
|
||||
document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400';
|
||||
document.getElementById('statusText').className = 'font-semibold text-gray-600 dark:text-gray-400';
|
||||
// Show "Benched" if not deployed, otherwise "No status data"
|
||||
document.getElementById('statusText').textContent = !currentUnit.deployed ? 'Benched' : 'No status data';
|
||||
const isAllocated = currentUnit.allocated && !currentUnit.deployed;
|
||||
document.getElementById('statusIndicator').className = isAllocated
|
||||
? 'w-3 h-3 rounded-full bg-orange-400'
|
||||
: 'w-3 h-3 rounded-full bg-gray-400';
|
||||
document.getElementById('statusText').className = isAllocated
|
||||
? 'font-semibold text-orange-500 dark:text-orange-400'
|
||||
: 'font-semibold text-gray-600 dark:text-gray-400';
|
||||
document.getElementById('statusText').textContent = isAllocated ? 'Allocated' : (!currentUnit.deployed ? 'Benched' : 'No status data');
|
||||
document.getElementById('lastSeen').textContent = '--';
|
||||
document.getElementById('age').textContent = '--';
|
||||
}
|
||||
@@ -833,6 +988,11 @@ function populateViewMode() {
|
||||
} else if (currentUnit.out_for_calibration) {
|
||||
document.getElementById('retiredStatus').textContent = 'Out for Calibration';
|
||||
document.getElementById('retiredStatus').className = 'font-medium text-purple-600 dark:text-purple-400';
|
||||
} else if (currentUnit.allocated && !currentUnit.deployed) {
|
||||
document.getElementById('retiredStatus').textContent = currentUnit.allocated_to_project_id
|
||||
? `Allocated — ${currentUnit.allocated_to_project_id}`
|
||||
: 'Allocated';
|
||||
document.getElementById('retiredStatus').className = 'font-medium text-orange-500 dark:text-orange-400';
|
||||
} else {
|
||||
document.getElementById('retiredStatus').textContent = 'Active';
|
||||
document.getElementById('retiredStatus').className = 'font-medium text-gray-900 dark:text-white';
|
||||
@@ -1032,6 +1192,10 @@ function populateEditForm() {
|
||||
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
||||
updateRetireButton(currentUnit.retired);
|
||||
document.getElementById('note').value = currentUnit.note || '';
|
||||
const allocatedChecked = currentUnit.allocated || false;
|
||||
document.getElementById('allocated').checked = allocatedChecked;
|
||||
document.getElementById('allocatedToProjectId').value = currentUnit.allocated_to_project_id || '';
|
||||
document.getElementById('allocatedProjectRow').style.display = allocatedChecked ? '' : 'none';
|
||||
|
||||
// Seismograph fields
|
||||
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
||||
@@ -1459,8 +1623,15 @@ async function uploadPhoto(file) {
|
||||
}
|
||||
}
|
||||
|
||||
// Load and display unit history timeline
|
||||
// Legacy timeline loader — Phase 4 unified the timeline view. Now a shim
|
||||
// that delegates to loadDeploymentTimeline() so existing callers from modal
|
||||
// save handlers still trigger a refresh of the visible section.
|
||||
async function loadUnitHistory() {
|
||||
if (typeof loadDeploymentTimeline === 'function') {
|
||||
return loadDeploymentTimeline();
|
||||
}
|
||||
}
|
||||
async function _legacy_loadUnitHistory_unused() {
|
||||
try {
|
||||
const response = await fetch(`/api/roster/history/${unitId}`);
|
||||
if (!response.ok) {
|
||||
@@ -1631,14 +1802,512 @@ async function pingModem() {
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Deployment History (legacy — Phase 4 superseded by deployment_timeline)
|
||||
// ============================================================
|
||||
|
||||
// Phase 4 shim: delegate to the unified timeline loader so existing modal
|
||||
// save handlers (legacy "Log Deployment" form, edit-save callbacks) still
|
||||
// trigger a refresh of the visible Deployment Timeline section.
|
||||
async function loadDeploymentHistory() {
|
||||
if (typeof loadDeploymentTimeline === 'function') {
|
||||
return loadDeploymentTimeline();
|
||||
}
|
||||
}
|
||||
async function _legacy_loadDeploymentHistory_unused() {
|
||||
try {
|
||||
const res = await fetch(`/api/deployments/${unitId}`);
|
||||
const data = await res.json();
|
||||
const container = document.getElementById('deploymentHistory');
|
||||
const deployments = data.deployments || [];
|
||||
|
||||
if (deployments.length === 0) {
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment records yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
deployments.forEach(d => {
|
||||
container.appendChild(createDeploymentRow(d));
|
||||
});
|
||||
} catch (e) {
|
||||
document.getElementById('deploymentHistory').innerHTML =
|
||||
'<p class="text-sm text-red-500">Failed to load deployment history.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateDisplay(iso) {
|
||||
if (!iso) return '—';
|
||||
const [y, m, d] = iso.split('-');
|
||||
return `${parseInt(m)}/${parseInt(d)}/${y}`;
|
||||
}
|
||||
|
||||
function createDeploymentRow(d) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'flex items-start gap-3 p-3 rounded-lg ' +
|
||||
(d.is_active
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||
: 'bg-gray-50 dark:bg-slate-700/50');
|
||||
|
||||
const statusDot = d.is_active
|
||||
? '<span class="mt-1 flex-shrink-0 w-2.5 h-2.5 rounded-full bg-green-500"></span>'
|
||||
: '<span class="mt-1 flex-shrink-0 w-2.5 h-2.5 rounded-full bg-gray-400 dark:bg-gray-500"></span>';
|
||||
|
||||
const jobLabel = d.project_ref || d.project_id || 'Unspecified job';
|
||||
const locLabel = d.location_name ? `<span class="text-gray-500 dark:text-gray-400"> · ${d.location_name}</span>` : '';
|
||||
|
||||
const deployedStr = formatDateDisplay(d.deployed_date);
|
||||
const estStr = d.estimated_removal_date ? formatDateDisplay(d.estimated_removal_date) : 'TBD';
|
||||
const actualStr = d.actual_removal_date ? formatDateDisplay(d.actual_removal_date) : null;
|
||||
|
||||
const dateRange = actualStr
|
||||
? `${deployedStr} → ${actualStr}`
|
||||
: `${deployedStr} → <span class="font-medium ${d.is_active ? 'text-green-700 dark:text-green-400' : 'text-gray-600 dark:text-gray-300'}">Est. ${estStr}</span>`;
|
||||
|
||||
const activeTag = d.is_active
|
||||
? '<span class="ml-2 px-1.5 py-0.5 text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-400 rounded">In Field</span>'
|
||||
: '';
|
||||
|
||||
div.innerHTML = `
|
||||
${statusDot}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
${jobLabel}${activeTag}${locLabel}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${dateRange}</div>
|
||||
${d.notes ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 italic">${d.notes}</div>` : ''}
|
||||
</div>
|
||||
<div class="flex gap-1 flex-shrink-0">
|
||||
<button onclick="openEditDeploymentModal(${JSON.stringify(d).replace(/"/g, '"')})"
|
||||
class="p-1.5 text-gray-400 hover:text-seismo-orange rounded transition-colors" title="Edit">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="deleteDeployment('${d.id}')"
|
||||
class="p-1.5 text-gray-400 hover:text-red-500 rounded transition-colors" title="Delete">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
function openNewDeploymentModal() {
|
||||
document.getElementById('deploymentModalTitle').textContent = 'Log Deployment';
|
||||
document.getElementById('deploymentModalId').value = '';
|
||||
document.getElementById('deploymentDeployedDate').value = '';
|
||||
document.getElementById('deploymentEstRemovalDate').value = '';
|
||||
document.getElementById('deploymentActualRemovalDate').value = '';
|
||||
document.getElementById('deploymentProjectRef').value = '';
|
||||
document.getElementById('deploymentLocationName').value = '';
|
||||
document.getElementById('deploymentNotes').value = '';
|
||||
document.getElementById('deploymentModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function openEditDeploymentModal(d) {
|
||||
document.getElementById('deploymentModalTitle').textContent = 'Edit Deployment';
|
||||
document.getElementById('deploymentModalId').value = d.id;
|
||||
document.getElementById('deploymentDeployedDate').value = d.deployed_date || '';
|
||||
document.getElementById('deploymentEstRemovalDate').value = d.estimated_removal_date || '';
|
||||
document.getElementById('deploymentActualRemovalDate').value = d.actual_removal_date || '';
|
||||
document.getElementById('deploymentProjectRef').value = d.project_ref || '';
|
||||
document.getElementById('deploymentLocationName').value = d.location_name || '';
|
||||
document.getElementById('deploymentNotes').value = d.notes || '';
|
||||
document.getElementById('deploymentModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeDeploymentModal() {
|
||||
document.getElementById('deploymentModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function saveDeployment() {
|
||||
const id = document.getElementById('deploymentModalId').value;
|
||||
const payload = {
|
||||
deployed_date: document.getElementById('deploymentDeployedDate').value || null,
|
||||
estimated_removal_date: document.getElementById('deploymentEstRemovalDate').value || null,
|
||||
actual_removal_date: document.getElementById('deploymentActualRemovalDate').value || null,
|
||||
project_ref: document.getElementById('deploymentProjectRef').value || null,
|
||||
location_name: document.getElementById('deploymentLocationName').value || null,
|
||||
notes: document.getElementById('deploymentNotes').value || null,
|
||||
};
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (id) {
|
||||
res = await fetch(`/api/deployments/${unitId}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} else {
|
||||
res = await fetch(`/api/deployments/${unitId}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
closeDeploymentModal();
|
||||
loadDeploymentHistory();
|
||||
} catch (e) {
|
||||
alert('Failed to save deployment: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDeployment(deploymentId) {
|
||||
if (!confirm('Delete this deployment record?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/deployments/${unitId}/${deploymentId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
loadDeploymentHistory();
|
||||
} catch (e) {
|
||||
alert('Failed to delete: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Load data when page loads
|
||||
loadCalibrationInterval();
|
||||
setupCalibrationAutoCalc();
|
||||
loadUnitData().then(() => {
|
||||
loadPhotos();
|
||||
loadUnitHistory();
|
||||
loadDeploymentTimeline();
|
||||
if (currentUnit && currentUnit.device_type === 'seismograph') {
|
||||
document.getElementById('sfmEventsSection').classList.remove('hidden');
|
||||
loadUnitEvents();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Unified Deployment Timeline (Phase 4) ────────────────────────────────────
|
||||
// Replaces the legacy loadDeploymentHistory() + loadUnitHistory() pair.
|
||||
// Derives entries from unit_assignments + unit_history + SFM event overlay.
|
||||
|
||||
async function loadDeploymentTimeline() {
|
||||
const container = document.getElementById('deploymentTimeline');
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>';
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/units/${currentUnit.id}/deployment_timeline`);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
renderDeploymentTimeline(d.entries || [], container);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<p class="text-sm text-red-500">Failed to load timeline: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _dtFmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
function _dtFmtDateTime(iso) {
|
||||
if (!iso) return '—';
|
||||
return iso.slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
function _dtEsc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _dtPpvClass(v) {
|
||||
if (v == null) return 'text-gray-400';
|
||||
if (v < 0.5) return 'text-green-600 dark:text-green-400';
|
||||
if (v < 2.0) return 'text-amber-600 dark:text-amber-400';
|
||||
return 'text-red-600 dark:text-red-400 font-semibold';
|
||||
}
|
||||
|
||||
function _dtRenderAssignment(e) {
|
||||
const start = _dtFmtDate(e.starts_at);
|
||||
const end = e.is_active ? 'present' : _dtFmtDate(e.ends_at);
|
||||
const dur = (e.duration_days != null)
|
||||
? `<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">(${e.duration_days.toFixed(1)} day${e.duration_days === 1 ? '' : 's'})</span>`
|
||||
: '';
|
||||
const ov = e.event_overlay || {};
|
||||
const evCount = ov.event_count ?? 0;
|
||||
const peak = ov.peak_pvs;
|
||||
|
||||
const locLink = e.location_id
|
||||
? `<a href="/projects/${_dtEsc(e.project_id)}/nrl/${_dtEsc(e.location_id)}" class="text-seismo-orange hover:text-seismo-navy font-medium">📍 ${_dtEsc(e.location_name || 'unnamed location')}</a>`
|
||||
: `<span class="text-gray-500 dark:text-gray-400 italic">📍 (no location FK — synthesized from legacy deployment_records)</span>`;
|
||||
|
||||
const projLine = e.project_name
|
||||
? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${_dtEsc(e.project_name)}</div>`
|
||||
: '';
|
||||
|
||||
const activeBadge = e.is_active
|
||||
? '<span class="px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
||||
: '';
|
||||
|
||||
const overlay = evCount > 0
|
||||
? `<div class="mt-2 flex items-center gap-4 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span><strong class="text-gray-900 dark:text-white">${evCount.toLocaleString()}</strong> event${evCount === 1 ? '' : 's'}</span>
|
||||
${peak != null ? `<span>peak <strong class="${_dtPpvClass(peak)}">${peak.toFixed(4)} in/s</strong></span>` : ''}
|
||||
${ov.last_event ? `<span>last ${_dtFmtDateTime(ov.last_event)}</span>` : ''}
|
||||
</div>`
|
||||
: `<div class="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">No events recorded during this window.</div>`;
|
||||
|
||||
const notes = e.notes
|
||||
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
|
||||
: '';
|
||||
|
||||
return `<div class="flex gap-3">
|
||||
<div class="flex flex-col items-center pt-1">
|
||||
<span class="w-3 h-3 rounded-full ${e.is_active ? 'bg-green-500' : 'bg-seismo-orange'}"></span>
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-50 dark:bg-slate-900/40 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
<strong>${start}</strong> → <strong>${end}</strong>${dur}
|
||||
</div>
|
||||
${activeBadge}
|
||||
</div>
|
||||
<div class="mt-1">${locLink}</div>
|
||||
${projLine}
|
||||
${overlay}
|
||||
${notes}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _dtRenderGap(e) {
|
||||
return `<div class="flex gap-3">
|
||||
<div class="flex flex-col items-center pt-1">
|
||||
<span class="w-3 h-3 rounded-full border-2 border-gray-400 dark:border-gray-500"></span>
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-50/40 dark:bg-slate-900/20 rounded-lg p-3 border border-dashed border-gray-300 dark:border-gray-700">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>${_dtFmtDate(e.starts_at)}</strong> → <strong>${_dtFmtDate(e.ends_at)}</strong>
|
||||
<span class="text-xs ml-2">(${e.duration_days.toFixed(1)} day${e.duration_days === 1 ? '' : 's'} idle)</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">No active assignment</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _dtRenderStateChange(e) {
|
||||
// Friendly labels for known change_types.
|
||||
const labels = {
|
||||
deployed_change: 'Deployed status changed',
|
||||
retired_change: 'Retired status changed',
|
||||
calibration_status_change: 'Calibration status changed',
|
||||
last_calibrated_change: 'Last calibrated updated',
|
||||
next_calibration_due_change: 'Next calibration due updated',
|
||||
allocation_change: 'Allocation changed',
|
||||
};
|
||||
const label = labels[e.change_type] || e.change_type;
|
||||
|
||||
return `<div class="flex gap-3">
|
||||
<div class="flex flex-col items-center pt-1">
|
||||
<span class="w-3 h-3 rounded-full bg-seismo-navy"></span>
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-50 dark:bg-slate-900/30 rounded-lg p-3">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
📅 <strong>${_dtFmtDateTime(e.starts_at)}</strong> — ${_dtEsc(label)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
${_dtEsc(e.old_value || '—')} → <strong>${_dtEsc(e.new_value || '—')}</strong>
|
||||
</div>
|
||||
${e.history_notes ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">${_dtEsc(e.history_notes)}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDeploymentTimeline(entries, container) {
|
||||
if (!entries.length) {
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment history yet. Assign this unit to a project location to start a deployment record.</p>';
|
||||
return;
|
||||
}
|
||||
const html = entries.map(e => {
|
||||
if (e.kind === 'assignment') return _dtRenderAssignment(e);
|
||||
if (e.kind === 'gap') return _dtRenderGap(e);
|
||||
if (e.kind === 'state_change') return _dtRenderStateChange(e);
|
||||
return '';
|
||||
}).join('');
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── SFM Events section ──────────────────────────────────────────────────────
|
||||
function clearUnitEventFilters() {
|
||||
document.getElementById('ue-filter-bucket').value = 'all';
|
||||
document.getElementById('ue-filter-from').value = '';
|
||||
document.getElementById('ue-filter-to').value = '';
|
||||
document.getElementById('ue-filter-ft').value = '';
|
||||
document.getElementById('ue-filter-limit').value = '500';
|
||||
loadUnitEvents();
|
||||
}
|
||||
|
||||
async function loadUnitEvents() {
|
||||
if (!currentUnit || currentUnit.device_type !== 'seismograph') return;
|
||||
const container = document.getElementById('ue-events-container');
|
||||
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading events…</div>';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const bucket = document.getElementById('ue-filter-bucket').value;
|
||||
const from = document.getElementById('ue-filter-from').value;
|
||||
const to = document.getElementById('ue-filter-to').value;
|
||||
const ft = document.getElementById('ue-filter-ft').value;
|
||||
const limit = document.getElementById('ue-filter-limit').value;
|
||||
params.set('bucket', bucket);
|
||||
if (from) params.set('from_dt', from.replace('T', ' '));
|
||||
if (to) params.set('to_dt', to.replace('T', ' '));
|
||||
if (ft) params.set('false_trigger', ft);
|
||||
params.set('limit', limit);
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/units/${currentUnit.id}/events?${params.toString()}`);
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||
}
|
||||
const d = await r.json();
|
||||
renderUnitEventStats(d.stats);
|
||||
renderUnitEventTable(d.events, d.count, container, bucket, d.assignments_total);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderUnitEventStats(stats) {
|
||||
const s = stats || {};
|
||||
document.getElementById('ue-stat-total').textContent = (s.event_count ?? 0).toLocaleString();
|
||||
const unattrEl = document.getElementById('ue-stat-unattr');
|
||||
unattrEl.textContent = (s.unattributed_count ?? 0).toLocaleString();
|
||||
// Highlight unattributed in amber/red if non-zero — visual signal that
|
||||
// the operator has assignment-window cleanup to do.
|
||||
unattrEl.className = 'text-2xl font-bold mt-1 ' + (
|
||||
(s.unattributed_count ?? 0) > 0
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
);
|
||||
|
||||
if (s.peak_pvs != null) {
|
||||
document.getElementById('ue-stat-peak').textContent = s.peak_pvs.toFixed(4) + ' in/s';
|
||||
const when = s.peak_pvs_at ? s.peak_pvs_at.slice(0, 10) : '';
|
||||
document.getElementById('ue-stat-peak-when').textContent = when || '—';
|
||||
} else {
|
||||
document.getElementById('ue-stat-peak').textContent = '—';
|
||||
document.getElementById('ue-stat-peak-when').textContent = '—';
|
||||
}
|
||||
|
||||
if (s.last_event) {
|
||||
const dt = s.last_event.slice(0, 19).replace('T', ' ');
|
||||
document.getElementById('ue-stat-last').textContent = dt;
|
||||
} else {
|
||||
document.getElementById('ue-stat-last').textContent = '—';
|
||||
}
|
||||
}
|
||||
|
||||
function _ueFmtPPV(v) {
|
||||
if (v == null) return '—';
|
||||
return v.toFixed(4);
|
||||
}
|
||||
|
||||
function _uePpvClass(v) {
|
||||
if (v == null) return 'text-gray-400';
|
||||
if (v < 0.5) return 'text-green-600 dark:text-green-400';
|
||||
if (v < 2.0) return 'text-amber-600 dark:text-amber-400';
|
||||
return 'text-red-600 dark:text-red-400 font-semibold';
|
||||
}
|
||||
|
||||
function _ueEsc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _ueAttrCell(ev) {
|
||||
// Inline links use onclick="event.stopPropagation()" so clicking the
|
||||
// project/location link navigates instead of opening the event-detail
|
||||
// modal (which fires from the row-level onclick).
|
||||
const a = ev.attribution;
|
||||
if (a) {
|
||||
const projLabel = _ueEsc(a.project_name || '—');
|
||||
const locLabel = _ueEsc(a.location_name || '—');
|
||||
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
|
||||
onclick="event.stopPropagation()"
|
||||
class="text-seismo-orange hover:text-seismo-navy"
|
||||
title="${projLabel} → ${locLabel}">
|
||||
📍 ${locLabel}
|
||||
</a>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
|
||||
}
|
||||
const n = ev.nearest_assignment;
|
||||
if (n) {
|
||||
const sign = n.delta_days < 0 ? 'before' : (n.delta_days > 0 ? 'after' : 'within boundary');
|
||||
const days = Math.abs(n.delta_days);
|
||||
const daysLabel = days < 1
|
||||
? `<${(days * 24).toFixed(1)}h`
|
||||
: `${days.toFixed(1)}d`;
|
||||
return `<span class="px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">⚠ Unattributed</span>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
${daysLabel} ${_ueEsc(sign)} <a href="/projects/${_ueEsc(n.project_id)}/nrl/${_ueEsc(n.location_id)}" onclick="event.stopPropagation()" class="text-seismo-orange hover:text-seismo-navy">${_ueEsc(n.location_name || '?')}</a>
|
||||
</div>`;
|
||||
}
|
||||
return `<span class="px-2 py-0.5 rounded text-xs bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">⚠ No assignments</span>`;
|
||||
}
|
||||
|
||||
function renderUnitEventTable(events, total, container, bucket, assignmentsTotal) {
|
||||
if (!events || events.length === 0) {
|
||||
let msg;
|
||||
if (bucket === 'unattributed') {
|
||||
msg = assignmentsTotal === 0
|
||||
? 'No assignments yet — every event from this unit is unattributed. Assign it to a project location to start attributing events.'
|
||||
: '✅ All events for this unit are attributed to a project/location.';
|
||||
} else if (bucket === 'attributed') {
|
||||
msg = assignmentsTotal === 0
|
||||
? 'No assignments yet for this unit.'
|
||||
: 'No events recorded inside any assignment window with the current filter.';
|
||||
} else {
|
||||
msg = 'No events found for this unit with the current filter.';
|
||||
}
|
||||
container.innerHTML = `<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">${msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = events.map(ev => {
|
||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||
const tran = _ueFmtPPV(ev.tran_ppv);
|
||||
const vert = _ueFmtPPV(ev.vert_ppv);
|
||||
const lng = _ueFmtPPV(ev.long_ppv);
|
||||
const pvs = _ueFmtPPV(ev.peak_vector_sum);
|
||||
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>'
|
||||
: '';
|
||||
|
||||
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-4 py-2.5 text-sm text-gray-900 dark:text-white whitespace-nowrap">${ts}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.tran_ppv)}">${tran}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.vert_ppv)}">${vert}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.long_ppv)}">${lng}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${_uePpvClass(ev.peak_vector_sum)}">${pvs}</td>
|
||||
<td class="px-4 py-2.5 text-sm">${ft}</td>
|
||||
<td class="px-4 py-2.5 text-sm">${_ueAttrCell(ev)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-3 pb-1">Showing ${events.length} of ${total.toLocaleString()} event${total === 1 ? '' : 's'}</div>
|
||||
<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-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Attribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// ===== Pair Device Modal Functions =====
|
||||
let pairModalModems = []; // Cache loaded modems
|
||||
let pairModalDeviceType = ''; // Current device type
|
||||
@@ -2187,4 +2856,8 @@ function showToast(message, type = 'info') {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Shared event-detail modal (clicking a row in the SFM Events table) #}
|
||||
{% include 'partials/event_detail_modal.html' %}
|
||||
<script src="/static/event-modal.js"></script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
{{ location.name }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Monitoring Location • {{ project.name }}
|
||||
Monitoring Location • {{ project.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@@ -65,6 +65,11 @@
|
||||
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
|
||||
Overview
|
||||
</button>
|
||||
<button onclick="switchTab('events')"
|
||||
data-tab="events"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||
Events
|
||||
</button>
|
||||
<button onclick="switchTab('settings')"
|
||||
data-tab="settings"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||
@@ -116,20 +121,36 @@
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
|
||||
{% if assigned_unit %}
|
||||
<div class="space-y-4">
|
||||
<!-- Seismograph row -->
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Seismograph</div>
|
||||
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||
{{ assigned_unit.id }}
|
||||
</a>
|
||||
</div>
|
||||
{% if assigned_unit.unit_type %}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ assigned_unit.unit_type }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if assigned_unit.device_type %}
|
||||
<!-- Modem row -->
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Device Type</div>
|
||||
<div class="text-gray-900 dark:text-white">{{ assigned_unit.device_type|capitalize }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Modem</div>
|
||||
{% if assigned_modem %}
|
||||
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
<a href="/unit/{{ assigned_modem.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||
{{ assigned_modem.id }}
|
||||
</a>
|
||||
</div>
|
||||
{% if assigned_modem.hardware_model or assigned_modem.ip_address %}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{{ assigned_modem.hardware_model or '' }}{% if assigned_modem.hardware_model and assigned_modem.ip_address %} • {% endif %}{{ assigned_modem.ip_address or '' }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-sm text-gray-400 dark:text-gray-500 italic">No modem paired</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if assignment %}
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
||||
@@ -142,10 +163,14 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="pt-2">
|
||||
<div class="pt-2 flex gap-2 flex-wrap">
|
||||
<button onclick="openSwapModal()"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors text-sm">
|
||||
Swap Unit / Modem
|
||||
</button>
|
||||
<button onclick="unassignUnit('{{ assignment.id }}')"
|
||||
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors">
|
||||
Unassign Unit
|
||||
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors text-sm">
|
||||
Unassign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,7 +180,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
||||
<button onclick="openAssignModal()"
|
||||
<button onclick="openSwapModal()"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
Assign a Unit
|
||||
</button>
|
||||
@@ -165,6 +190,152 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events Tab -->
|
||||
<div id="events-tab" class="tab-panel hidden">
|
||||
<!-- Summary stats -->
|
||||
<div id="events-stats" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
|
||||
<span id="ev-stat-count" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Overall Peak</span>
|
||||
<span id="ev-stat-peak" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
<span id="ev-stat-peak-when" class="text-xs text-gray-500 dark:text-gray-400 mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
|
||||
<span id="ev-stat-last" class="text-lg font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">False Triggers</span>
|
||||
<span id="ev-stat-ft" class="text-3xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignments used (transparency: which seismographs contributed events) -->
|
||||
<div id="events-assignments-card" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 hidden">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">Seismographs deployed at this location</h3>
|
||||
<span id="ev-assignments-count" class="text-xs text-gray-500 dark:text-gray-400"></span>
|
||||
</div>
|
||||
<div id="ev-assignments-list" class="divide-y divide-gray-200 dark:divide-gray-700"></div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||
<span class="inline-block w-4 text-center">✎</span>
|
||||
Click the pencil to backdate a deployment so historical events get attributed to this location.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Edit-assignment modal -->
|
||||
<div id="assignment-edit-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Edit Deployment Window</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span id="ae-unit-label" class="font-mono text-seismo-orange">—</span>
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="closeAssignmentEditModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
|
||||
<svg class="w-6 h-6" 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"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="assignment-edit-form" class="p-6 space-y-4">
|
||||
<input type="hidden" id="ae-assignment-id">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned From</label>
|
||||
<input type="datetime-local" id="ae-assigned-at" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Assigned Until
|
||||
<span class="text-xs text-gray-500 ml-1">(leave blank if still active)</span>
|
||||
</label>
|
||||
<input type="datetime-local" id="ae-assigned-until"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||
<textarea id="ae-notes" rows="2" placeholder="Optional — e.g. 'backdated to reflect physical install date'"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
||||
</div>
|
||||
|
||||
<div id="ae-error" class="hidden text-sm text-red-600"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onclick="closeAssignmentEditModal()"
|
||||
class="px-4 py-2 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 type="submit" id="ae-submit-btn"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6">
|
||||
<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">From</label>
|
||||
<input type="datetime-local" id="ev-filter-from" onchange="loadLocationEvents()"
|
||||
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="ev-filter-to" onchange="loadLocationEvents()"
|
||||
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="ev-filter-ft" onchange="loadLocationEvents()"
|
||||
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 Events</option>
|
||||
<option value="false">Real Events Only</option>
|
||||
<option value="true">False Triggers 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="ev-filter-limit" onchange="loadLocationEvents()"
|
||||
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="250">250</option>
|
||||
<option value="500" selected>500</option>
|
||||
<option value="1000">1000</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="clearEventFilters()"
|
||||
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>
|
||||
<button onclick="loadLocationEvents()"
|
||||
class="ml-auto px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event table -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
|
||||
<div id="events-container" class="overflow-x-auto">
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>
|
||||
Loading events…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div id="settings-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
@@ -214,47 +385,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assign Unit Modal -->
|
||||
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<!-- Assign / Swap Modal -->
|
||||
<div id="swap-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a seismograph to this location</p>
|
||||
<h2 id="swap-modal-title" class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a seismograph and optionally a modem for this location</p>
|
||||
</div>
|
||||
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<button onclick="closeSwapModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg class="w-6 h-6" 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"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="assign-form" class="p-6 space-y-4">
|
||||
<form id="swap-form" class="p-6 space-y-5">
|
||||
<!-- Seismograph picker -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
|
||||
<select id="assign-unit-id" name="unit_id"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||
<option value="">Loading units...</option>
|
||||
</select>
|
||||
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available seismographs for this project.</p>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Seismograph <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="swap-unit-search" type="text" placeholder="Search by ID or model..."
|
||||
oninput="filterSwapList('unit')"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm mb-2 focus:ring-2 focus:ring-seismo-orange">
|
||||
<div id="swap-unit-list"
|
||||
class="max-h-48 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700 bg-white dark:bg-gray-700">
|
||||
<div class="px-3 py-6 text-center text-sm text-gray-400">Loading...</div>
|
||||
</div>
|
||||
<input type="hidden" id="swap-unit-id" name="unit_id" required>
|
||||
<p id="swap-units-empty" class="hidden text-xs text-gray-500 mt-1">No available seismographs.</p>
|
||||
</div>
|
||||
|
||||
<!-- Modem picker -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Modem <span class="text-xs text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<input id="swap-modem-search" type="text" placeholder="Search by ID, model, or IP..."
|
||||
oninput="filterSwapList('modem')"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm mb-2 focus:ring-2 focus:ring-seismo-orange">
|
||||
<div id="swap-modem-list"
|
||||
class="max-h-40 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700 bg-white dark:bg-gray-700">
|
||||
<div class="px-3 py-6 text-center text-sm text-gray-400">Loading...</div>
|
||||
</div>
|
||||
<input type="hidden" id="swap-modem-id" name="modem_id">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||
<textarea id="assign-notes" name="notes" rows="2"
|
||||
<textarea id="swap-notes" name="notes" rows="2"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
||||
</div>
|
||||
|
||||
<div id="assign-error" class="hidden text-sm text-red-600"></div>
|
||||
<div id="swap-error" class="hidden text-sm text-red-600"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onclick="closeAssignModal()"
|
||||
<button type="button" onclick="closeSwapModal()"
|
||||
class="px-6 py-2 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 type="submit"
|
||||
<button type="submit" id="swap-submit-btn"
|
||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||
Assign Unit
|
||||
Assign
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -264,6 +457,7 @@
|
||||
<script>
|
||||
const projectId = "{{ project_id }}";
|
||||
const locationId = "{{ location_id }}";
|
||||
const hasAssignment = {{ 'true' if assigned_unit else 'false' }};
|
||||
|
||||
// Tab switching
|
||||
function switchTab(tabName) {
|
||||
@@ -281,6 +475,258 @@ function switchTab(tabName) {
|
||||
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||
}
|
||||
// Lazy-load Events tab on first visit (or whenever it's reopened).
|
||||
if (tabName === 'events' && !_eventsLoaded) {
|
||||
loadLocationEvents();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events tab ───────────────────────────────────────────────────────────────
|
||||
let _eventsLoaded = false;
|
||||
|
||||
function clearEventFilters() {
|
||||
document.getElementById('ev-filter-from').value = '';
|
||||
document.getElementById('ev-filter-to').value = '';
|
||||
document.getElementById('ev-filter-ft').value = '';
|
||||
document.getElementById('ev-filter-limit').value = '500';
|
||||
loadLocationEvents();
|
||||
}
|
||||
|
||||
async function loadLocationEvents() {
|
||||
const container = document.getElementById('events-container');
|
||||
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Loading events…</div>';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const from = document.getElementById('ev-filter-from').value;
|
||||
const to = document.getElementById('ev-filter-to').value;
|
||||
const ft = document.getElementById('ev-filter-ft').value;
|
||||
const limit = document.getElementById('ev-filter-limit').value;
|
||||
if (from) params.set('from_dt', from.replace('T', ' '));
|
||||
if (to) params.set('to_dt', to.replace('T', ' '));
|
||||
if (ft) params.set('false_trigger', ft);
|
||||
params.set('limit', limit);
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${projectId}/locations/${locationId}/events?${params.toString()}`);
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||
}
|
||||
const d = await r.json();
|
||||
_eventsLoaded = true;
|
||||
renderEventStats(d.stats);
|
||||
renderAssignmentsUsed(d.assignments_used);
|
||||
renderEventTable(d.events, d.count, container);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEventStats(stats) {
|
||||
const s = stats || {};
|
||||
document.getElementById('ev-stat-count').textContent = (s.event_count ?? 0).toLocaleString();
|
||||
document.getElementById('ev-stat-ft').textContent = (s.false_trigger_count ?? 0).toLocaleString();
|
||||
|
||||
if (s.peak_pvs != null) {
|
||||
document.getElementById('ev-stat-peak').textContent = s.peak_pvs.toFixed(4) + ' in/s';
|
||||
const when = s.peak_pvs_at ? s.peak_pvs_at.slice(0, 10) : '';
|
||||
const who = s.peak_pvs_serial || '';
|
||||
document.getElementById('ev-stat-peak-when').textContent = [when, who].filter(Boolean).join(' · ') || '—';
|
||||
} else {
|
||||
document.getElementById('ev-stat-peak').textContent = '—';
|
||||
document.getElementById('ev-stat-peak-when').textContent = '—';
|
||||
}
|
||||
|
||||
if (s.last_event) {
|
||||
const dt = s.last_event.slice(0, 19).replace('T', ' ');
|
||||
document.getElementById('ev-stat-last').textContent = dt;
|
||||
} else {
|
||||
document.getElementById('ev-stat-last').textContent = '—';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssignmentsUsed(assignments) {
|
||||
const card = document.getElementById('events-assignments-card');
|
||||
const listEl = document.getElementById('ev-assignments-list');
|
||||
const countEl = document.getElementById('ev-assignments-count');
|
||||
|
||||
if (!assignments || assignments.length === 0) {
|
||||
card.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
card.classList.remove('hidden');
|
||||
countEl.textContent = `${assignments.length} assignment${assignments.length === 1 ? '' : 's'}`;
|
||||
|
||||
listEl.innerHTML = assignments.map(a => {
|
||||
const start = a.assigned_at ? a.assigned_at.slice(0, 10) : '?';
|
||||
const end = a.assigned_until ? a.assigned_until.slice(0, 10) : 'present';
|
||||
const isActive = !a.assigned_until;
|
||||
const badge = isActive
|
||||
? '<span class="ml-2 px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
||||
: '';
|
||||
const editAttr = encodeURIComponent(JSON.stringify({
|
||||
id: a.assignment_id,
|
||||
unit_id: a.unit_id,
|
||||
assigned_at: a.assigned_at,
|
||||
assigned_until: a.assigned_until,
|
||||
}));
|
||||
return `<div class="py-2 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<a href="/unit/${esc(a.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">${esc(a.unit_id)}</a>
|
||||
${badge}
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">${start} → ${end}</span>
|
||||
<button type="button"
|
||||
onclick="openAssignmentEditModal('${editAttr}')"
|
||||
title="Edit deployment dates"
|
||||
class="text-gray-400 hover:text-seismo-orange transition-colors p-1">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Assignment-edit modal ───────────────────────────────────────────────────
|
||||
function _isoToInputValue(iso) {
|
||||
// Convert "2026-04-14T02:19:27" (or "2026-04-14 02:19:27") to "2026-04-14T02:19" for datetime-local input.
|
||||
if (!iso) return '';
|
||||
const cleaned = iso.replace(' ', 'T');
|
||||
return cleaned.slice(0, 16);
|
||||
}
|
||||
|
||||
function openAssignmentEditModal(encodedJson) {
|
||||
const data = JSON.parse(decodeURIComponent(encodedJson));
|
||||
document.getElementById('ae-assignment-id').value = data.id;
|
||||
document.getElementById('ae-unit-label').textContent = data.unit_id;
|
||||
document.getElementById('ae-assigned-at').value = _isoToInputValue(data.assigned_at);
|
||||
document.getElementById('ae-assigned-until').value = _isoToInputValue(data.assigned_until);
|
||||
document.getElementById('ae-notes').value = '';
|
||||
document.getElementById('ae-error').classList.add('hidden');
|
||||
document.getElementById('assignment-edit-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeAssignmentEditModal() {
|
||||
document.getElementById('assignment-edit-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('assignment-edit-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('ae-error');
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
const assignmentId = document.getElementById('ae-assignment-id').value;
|
||||
const assignedAt = document.getElementById('ae-assigned-at').value;
|
||||
const assignedUntil = document.getElementById('ae-assigned-until').value;
|
||||
const notes = document.getElementById('ae-notes').value.trim();
|
||||
|
||||
if (!assignedAt) {
|
||||
errEl.textContent = 'Assigned From is required.';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { assigned_at: assignedAt };
|
||||
payload.assigned_until = assignedUntil || null;
|
||||
if (notes) payload.notes = notes;
|
||||
|
||||
const btn = document.getElementById('ae-submit-btn');
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||
}
|
||||
closeAssignmentEditModal();
|
||||
await loadLocationEvents(); // Refresh stats + table with new window.
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || 'Failed to update assignment.';
|
||||
errEl.classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Save';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('assignment-edit-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeAssignmentEditModal();
|
||||
});
|
||||
|
||||
function renderEventTable(events, total, container) {
|
||||
if (!events || events.length === 0) {
|
||||
const haveAssignments = !document.getElementById('events-assignments-card').classList.contains('hidden');
|
||||
const msg = haveAssignments
|
||||
? 'No events recorded for the assignments above within the current filter.'
|
||||
: 'No seismographs have been assigned to this location yet. Assign one to start collecting events.';
|
||||
container.innerHTML = `<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">${msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = events.map(ev => {
|
||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||
const tran = fmtPPV(ev.tran_ppv);
|
||||
const vert = fmtPPV(ev.vert_ppv);
|
||||
const lng = fmtPPV(ev.long_ppv);
|
||||
const pvs = fmtPPV(ev.peak_vector_sum);
|
||||
const mic = ev.mic_ppv != null ? ev.mic_ppv.toFixed(3) : '—';
|
||||
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>'
|
||||
: '';
|
||||
|
||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer" onclick="showEventDetail('${esc(ev.id)}')">
|
||||
<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 font-medium text-seismo-orange">
|
||||
<a href="/unit/${esc(ev.serial)}" class="hover:text-seismo-navy" onclick="event.stopPropagation()">${esc(ev.serial)}</a>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.tran_ppv)}">${tran}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.vert_ppv)}">${vert}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.long_ppv)}">${lng}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${ppvClass(ev.peak_vector_sum)}">${pvs}</td>
|
||||
<td class="px-4 py-2.5 text-sm font-mono text-gray-600 dark:text-gray-400">${mic}</td>
|
||||
<td class="px-4 py-2.5 text-sm">${ft}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-3">Showing ${events.length} of ${total.toLocaleString()} events</div>
|
||||
<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-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Serial</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Mic</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function fmtPPV(v) {
|
||||
if (v == null) return '—';
|
||||
return v.toFixed(4);
|
||||
}
|
||||
|
||||
function ppvClass(v) {
|
||||
if (v == null) return 'text-gray-400';
|
||||
if (v < 0.5) return 'text-green-600 dark:text-green-400';
|
||||
if (v < 2.0) return 'text-amber-600 dark:text-amber-400';
|
||||
return 'text-red-600 dark:text-red-400 font-semibold';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Location settings form submission
|
||||
@@ -314,60 +760,156 @@ document.getElementById('location-settings-form').addEventListener('submit', asy
|
||||
}
|
||||
});
|
||||
|
||||
// Assign modal
|
||||
function openAssignModal() {
|
||||
document.getElementById('assign-modal').classList.remove('hidden');
|
||||
loadAvailableUnits();
|
||||
// Swap / Assign modal
|
||||
async function openSwapModal() {
|
||||
document.getElementById('swap-modal').classList.remove('hidden');
|
||||
document.getElementById('swap-modal-title').textContent = hasAssignment ? 'Swap Unit / Modem' : 'Assign Unit';
|
||||
document.getElementById('swap-submit-btn').textContent = hasAssignment ? 'Swap' : 'Assign';
|
||||
document.getElementById('swap-error').classList.add('hidden');
|
||||
document.getElementById('swap-notes').value = '';
|
||||
document.getElementById('swap-unit-search').value = '';
|
||||
document.getElementById('swap-modem-search').value = '';
|
||||
document.getElementById('swap-unit-id').value = '';
|
||||
document.getElementById('swap-modem-id').value = '';
|
||||
await Promise.all([loadSwapUnits(), loadSwapModems()]);
|
||||
}
|
||||
|
||||
function closeAssignModal() {
|
||||
document.getElementById('assign-modal').classList.add('hidden');
|
||||
function closeSwapModal() {
|
||||
document.getElementById('swap-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function loadAvailableUnits() {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
||||
if (!response.ok) throw new Error('Failed to load available units');
|
||||
const data = await response.json();
|
||||
const select = document.getElementById('assign-unit-id');
|
||||
select.innerHTML = '<option value="">Select a unit</option>';
|
||||
let _swapUnits = [];
|
||||
let _swapModems = [];
|
||||
|
||||
if (!data.length) {
|
||||
document.getElementById('assign-empty').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
function _fuzzyMatch(query, text) {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
const t = text.toLowerCase();
|
||||
// Substring match first (fast), then character-sequence fuzzy
|
||||
if (t.includes(q)) return true;
|
||||
let qi = 0;
|
||||
for (let i = 0; i < t.length && qi < q.length; i++) {
|
||||
if (t[i] === q[qi]) qi++;
|
||||
}
|
||||
return qi === q.length;
|
||||
}
|
||||
|
||||
data.forEach(unit => {
|
||||
const option = document.createElement('option');
|
||||
option.value = unit.id;
|
||||
option.textContent = `${unit.id} • ${unit.model || unit.device_type}`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
} catch (err) {
|
||||
const errorEl = document.getElementById('assign-error');
|
||||
errorEl.textContent = err.message || 'Failed to load units.';
|
||||
errorEl.classList.remove('hidden');
|
||||
function _renderSwapList(type, items, selectedId, noSelectionLabel) {
|
||||
const listEl = document.getElementById(`swap-${type}-list`);
|
||||
if (!items.length) {
|
||||
listEl.innerHTML = `<div class="px-3 py-4 text-center text-sm text-gray-400">No results</div>`;
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = items.map(item => {
|
||||
const isSelected = item.value === selectedId;
|
||||
return `<button type="button"
|
||||
onclick="selectSwapItem('${type}', '${item.value}', this)"
|
||||
class="w-full text-left px-3 py-2.5 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors ${isSelected ? 'bg-orange-50 dark:bg-orange-900/20' : ''}">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-white text-sm">${item.primary}</span>
|
||||
${item.secondary ? `<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">${item.secondary}</span>` : ''}
|
||||
</div>
|
||||
<div class="w-4 h-4 rounded-full border-2 shrink-0 ml-3 ${isSelected ? 'border-seismo-orange bg-seismo-orange' : 'border-gray-400 dark:border-gray-500'}"></div>
|
||||
</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function selectSwapItem(type, value, btn) {
|
||||
document.getElementById(`swap-${type}-id`).value = value;
|
||||
// Update visual state
|
||||
const list = document.getElementById(`swap-${type}-list`);
|
||||
list.querySelectorAll('button').forEach(b => {
|
||||
b.classList.remove('bg-orange-50', 'dark:bg-orange-900/20');
|
||||
b.querySelector('.rounded-full').className = 'w-4 h-4 rounded-full border-2 shrink-0 ml-3 border-gray-400 dark:border-gray-500';
|
||||
});
|
||||
btn.classList.add('bg-orange-50', 'dark:bg-orange-900/20');
|
||||
btn.querySelector('.rounded-full').className = 'w-4 h-4 rounded-full border-2 shrink-0 ml-3 border-seismo-orange bg-seismo-orange';
|
||||
}
|
||||
|
||||
function filterSwapList(type) {
|
||||
const query = document.getElementById(`swap-${type}-search`).value;
|
||||
const items = type === 'unit' ? _swapUnits : _swapModems;
|
||||
const selectedId = document.getElementById(`swap-${type}-id`).value;
|
||||
const filtered = items.filter(item =>
|
||||
_fuzzyMatch(query, item.primary + ' ' + (item.secondary || '') + ' ' + (item.searchText || ''))
|
||||
);
|
||||
_renderSwapList(type, filtered, selectedId, type === 'modem' ? 'No modem' : null);
|
||||
// Re-add "No modem" option for modems
|
||||
if (type === 'modem') {
|
||||
const listEl = document.getElementById('swap-modem-list');
|
||||
const noModemBtn = `<button type="button"
|
||||
onclick="selectSwapItem('modem', '', this)"
|
||||
class="w-full text-left px-3 py-2.5 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors ${!selectedId ? 'bg-orange-50 dark:bg-orange-900/20' : ''}">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 italic">No modem</span>
|
||||
<div class="w-4 h-4 rounded-full border-2 shrink-0 ml-3 ${!selectedId ? 'border-seismo-orange bg-seismo-orange' : 'border-gray-400 dark:border-gray-500'}"></div>
|
||||
</button>`;
|
||||
listEl.insertAdjacentHTML('afterbegin', noModemBtn);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('assign-form').addEventListener('submit', async function(e) {
|
||||
async function loadSwapUnits() {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
||||
if (!response.ok) throw new Error('Failed to load units');
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.length) {
|
||||
document.getElementById('swap-units-empty').classList.remove('hidden');
|
||||
document.getElementById('swap-unit-list').innerHTML = '<div class="px-3 py-4 text-center text-sm text-gray-400">No available seismographs.</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('swap-units-empty').classList.add('hidden');
|
||||
_swapUnits = data.map(u => ({
|
||||
value: u.id,
|
||||
primary: u.id,
|
||||
secondary: [u.model, u.location].filter(Boolean).join(' — '),
|
||||
searchText: u.model + ' ' + u.location,
|
||||
}));
|
||||
_renderSwapList('unit', _swapUnits, document.getElementById('swap-unit-id').value);
|
||||
} catch (err) {
|
||||
document.getElementById('swap-error').textContent = 'Failed to load seismographs.';
|
||||
document.getElementById('swap-error').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSwapModems() {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/available-modems`);
|
||||
if (!response.ok) throw new Error('Failed to load modems');
|
||||
const data = await response.json();
|
||||
_swapModems = data.map(m => ({
|
||||
value: m.id,
|
||||
primary: m.id,
|
||||
secondary: [m.hardware_model, m.ip_address].filter(Boolean).join(' — '),
|
||||
searchText: (m.hardware_model || '') + ' ' + (m.ip_address || ''),
|
||||
}));
|
||||
filterSwapList('modem'); // renders with "No modem" prepended
|
||||
} catch (err) {
|
||||
console.warn('Failed to load modems:', err);
|
||||
document.getElementById('swap-modem-list').innerHTML = '<div class="px-3 py-4 text-center text-sm text-gray-400">Could not load modems.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('swap-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const unitId = document.getElementById('assign-unit-id').value;
|
||||
const notes = document.getElementById('assign-notes').value.trim();
|
||||
const unitId = document.getElementById('swap-unit-id').value;
|
||||
const modemId = document.getElementById('swap-modem-id').value;
|
||||
const notes = document.getElementById('swap-notes').value.trim();
|
||||
|
||||
if (!unitId) {
|
||||
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
|
||||
document.getElementById('assign-error').classList.remove('hidden');
|
||||
document.getElementById('swap-error').textContent = 'Please select a seismograph.';
|
||||
document.getElementById('swap-error').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('unit_id', unitId);
|
||||
formData.append('notes', notes);
|
||||
if (modemId) formData.append('modem_id', modemId);
|
||||
if (notes) formData.append('notes', notes);
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
|
||||
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/swap`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
@@ -379,9 +921,8 @@ document.getElementById('assign-form').addEventListener('submit', async function
|
||||
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
const errorEl = document.getElementById('assign-error');
|
||||
errorEl.textContent = err.message || 'Failed to assign unit.';
|
||||
errorEl.classList.remove('hidden');
|
||||
document.getElementById('swap-error').textContent = err.message || 'Failed to assign unit.';
|
||||
document.getElementById('swap-error').classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -405,11 +946,15 @@ async function unassignUnit(assignmentId) {
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeAssignModal();
|
||||
if (e.key === 'Escape') closeSwapModal();
|
||||
});
|
||||
|
||||
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeAssignModal();
|
||||
document.getElementById('swap-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeSwapModal();
|
||||
});
|
||||
</script>
|
||||
|
||||
{# Shared event-detail modal (clicking an event row in the Events tab) #}
|
||||
{% include 'partials/event_detail_modal.html' %}
|
||||
<script src="/static/event-modal.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user