35 Commits

Author SHA1 Message Date
5ea64c3561 Merge pull request 'merge watcher from dev to main (0.8.0)' (#34) from dev into main
Reviewed-on: #34
2026-03-18 16:05:46 -04:00
1a87ff13c9 doc: update docs for 0.8.0 2026-03-18 19:59:34 +00:00
22c62c0729 fix: watcher manager now displays "heard from" status, instead of unit status. Refreshes ever 60 mins, if havent heard from in >60 mins display missing. 2026-03-18 19:45:39 +00:00
0f47b69c92 Merge pull request 'merge watcher into dev' (#33) from watcher into dev
Reviewed-on: #33
2026-03-18 14:54:33 -04:00
76667454b3 feat: enhance agent status display with new classes and live refresh functionality 2026-03-18 18:51:54 +00:00
serversdwn
15d962ba42 feat: watcher agent management system implemented. 2026-03-13 17:38:43 -04:00
serversdwn
b571dc29bc chore: make rebuild script executable 2026-03-13 17:34:32 +00:00
serversdwn
e2c841d5d7 doc: update readme v0.7.1 2026-03-12 22:41:47 +00:00
cc94493331 Merge pull request 'merge v0.7.1 dev into main.' (#31) from dev into main
Reviewed-on: #31
2026-03-12 18:40:15 -04:00
serversdwn
5a5426cceb v0.7.1 - Add out for call status, starting to work on reservation mode, fixed a big brain fart too. 2026-03-12 22:38:22 +00:00
serversdwn
66eddd6fe2 chore: db cleanups and migration script fixes. 2026-03-12 22:09:57 +00:00
serversdwn
c77794787c remove override 2026-03-12 21:41:30 +00:00
61c84bc71d fix: merge conflict fixed 2026-03-12 21:37:09 +00:00
fbf7f2a65d chore: clean up .gitignore 2026-03-12 21:22:51 +00:00
serversdwn
202fcaf91c Merge branch 'dev' of ssh://10.0.0.2:2222/serversdown/terra-view into dev 2026-03-12 20:25:02 +00:00
serversdwn
3a411d0a89 chore: docker and git stuff 2026-03-12 20:24:01 +00:00
0c2186f5d8 feat: reservation modal now usable. 2026-03-12 20:10:42 +00:00
c138e8c6a0 feat: add new "out for cal" status for units currently being calibrated.
-retire unit button changed to be more dramatic... lol
2026-03-12 17:59:42 +00:00
1dd396acd8 Merge pull request 'Update to v0.7.0' (#30) from dev into main
Reviewed-on: #30
2026-03-08 03:13:17 -04:00
e89a04f58c fix: SLM report line graph border added, combined report wizard spacing fix. 2026-03-07 07:16:10 +00:00
e4ef065db8 Version bump to v0.7.0.
Docs: Update readme/changelog for 0.7.0
2026-03-07 01:39:19 +00:00
86010de60c Fix: combined report generation formatting fixed and cleaned up. (i think its good now?) 2026-03-07 01:32:49 +00:00
f89f04cd6f feat: support for day time monitoring data, combined report generation now compaitible with mixed day and night types. 2026-03-07 00:16:58 +00:00
67a2faa2d3 fix: separate days now are in separate .xlsx files, NRLs still 1 per sheet.
add: rebuild script for prod.

fix: Improved data parsing, now filters out unneeded Lp files and .xlsx files.
2026-03-06 23:37:24 +00:00
14856e61ef Feat: Full combined report now working properly. Lotsa stuff fixed. 2026-03-06 22:32:54 +00:00
2b69518b33 fix: add slm start time grace_minutes=15 grace period to include data starting at start time. 2026-03-05 22:48:21 +00:00
6070d03e83 fix: nl32 data date now reads from start_time. 2026-03-05 22:28:10 +00:00
240552751c feat: enhance mass upload parsing, no longer imports tons of unneeded Lp Files. 2026-03-05 22:22:19 +00:00
015ce0a254 feat: add data collection mode to projects with UI updates and migration script 2026-03-05 21:50:41 +00:00
ef8c046f31 feat: add slm model schemas, please run migration on prod db
Feat: add complete combined sound report creation tool (wizard), add new slm schema for each model

feat: update project header link for combined report wizard

feat: add migration script to backfill device_model in monitoring_sessions

feat: implement combined report preview template with spreadsheet functionality

feat: create combined report wizard template for report generation.
2026-03-05 20:43:22 +00:00
3637cf5af8 Feat: Chart preview function now working.
fix: chart formating and styling tweaks to match typical reports.
2026-03-05 06:56:44 +00:00
7fde14d882 feat: add support for nl32 data in webviewer and report generator. 2026-03-05 04:19:34 +00:00
b15d434fce Merge pull request 'version bump 0.6.1' (#28) from dev into main
Reviewed-on: #28
2026-02-15 23:47:22 -05:00
7b4e12c127 Merge pull request 'merge dev 0.6.1 to main.' (#27) from dev into main
Reviewed-on: #27
2026-02-15 23:44:19 -05:00
742a98a8ed Merge pull request 'version bump to 0.6' (#26) from dev into main
Reviewed-on: #26
2026-02-06 16:42:06 -05:00
42 changed files with 4309 additions and 521 deletions

17
.gitignore vendored
View File

@@ -1,4 +1,8 @@
# Terra-View Specifics
# Dev build counter (local only, never commit)
build_number.txt
docker-compose.override.yml
# SQLite database files
*.db
*.db-journal
@@ -6,7 +10,7 @@ data/
data-dev/
.aider*
.aider*
docker-compose.override.yml
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -216,3 +220,14 @@ marimo/_static/
marimo/_lsp/
__marimo__/
<<<<<<< HEAD
# Seismo Fleet Manager
# SQLite database files
*.db
*.db-journal
/data/
/data-dev/
.aider*
.aider*
=======
>>>>>>> 0c2186f5d89d948b0357d674c0773a67a67d8027

View File

@@ -5,6 +5,98 @@ 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.8.0] - 2026-03-18
### Added
- **Watcher Manager**: New admin page (`/admin/watchers`) for monitoring field watcher agents
- Live status cards per agent showing connectivity, version, IP, last-seen age, and log tail
- Trigger Update button to queue a self-update on the agent's next heartbeat
- Expand/collapse log tail with full-log expand mode
- Live surgical refresh every 30 seconds via `/api/admin/watchers` — no full page reload, open logs stay open
### Changed
- **Watcher status logic**: Agent status now reflects whether Terra-View is hearing from the watcher (ok if seen within 60 minutes, missing otherwise) — previously reflected the worst unit status from the last heartbeat payload, which caused false alarms when units went missing
### Fixed
- **Watcher Manager meta row**: Dark mode background was white due to invalid `dark:bg-slate-850` Tailwind class; corrected to `dark:bg-slate-800`
---
## [0.7.1] - 2026-03-12
### Added
- **"Out for Calibration" Unit Status**: New `out_for_cal` status for units currently away for calibration, with visual indicators in the roster, unit list, and seismograph stats panel
- **Reservation Modal**: Fleet calendar reservation modal is now fully functional for creating and managing device reservations
### Changed
- **Retire Unit Button**: Redesigned to be more visually prominent/destructive to reduce accidental clicks
### Fixed
- **Migration Scripts**: Fixed database path references in several migration scripts
- **Docker Compose**: Removed dev override file from the repository; dev environment config kept separate
### Migration Notes
Run the following migration script once per database before deploying:
```bash
python backend/migrate_add_out_for_calibration.py
```
---
## [0.7.0] - 2026-03-07
### Added
- **Project Status Management**: Projects can now be placed `on_hold` or `archived`, with automatic cancellation of pending scheduled actions
- **Hard Delete Projects**: Support for permanently deleting projects, in addition to soft-delete with auto-pruning
- **Vibration Location Detail**: New dedicated template for vibration project location detail views
- **Vibration Project Isolation**: Vibration projects no longer show SLM-specific project tabs
- **Manual SD Card Data Upload**: Upload offline NRL data directly from SD card via ZIP or multi-file select
- Accepts `.rnd`/`.rnh` files; parses `.rnh` metadata for session start/stop times, serial number, and store name
- Creates `MonitoringSession` and `DataFile` records automatically; no unit assignment required
- Upload panel on NRL detail Data Files tab with inline feedback and auto-refresh via HTMX
- **Standalone SLM Type**: New SLM device mode that operates without a modem (direct IP connection)
- **NL32 Data Support**: Report generator and web viewer now support NL32 measurement data format
- **Combined Report Wizard**: Multi-session combined Excel report generation tool
- Wizard UI grouped by location with period type badges (day/night)
- Each selected session produces one `.xlsx` in a ZIP archive
- Period type filtering: day sessions keep last calendar date (7AM6:59PM); night sessions span both days (7PM6:59AM)
- **Combined Report Preview**: Interactive spreadsheet-style preview before generating combined reports
- **Chart Preview**: Live chart preview in the report generator matching final report styling
- **SLM Model Schemas**: Per-model configuration schemas for NL32, NL43, NL53 devices
- **Data Collection Mode**: Projects now store a data collection mode field with UI controls and migration
### Changed
- **MonitoringSession rename**: `RecordingSession` renamed to `MonitoringSession` throughout codebase; DB table renamed from `recording_sessions` to `monitoring_sessions`
- Migration: `backend/migrate_rename_recording_to_monitoring_sessions.py`
- **Combined Report Split Logic**: Separate days now generate separate `.xlsx` files; NRLs remain one per sheet
- **Mass Upload Parsing**: Smarter file filtering — no longer imports unneeded Lp files or `.xlsx` files
- **SLM Start Time Grace Period**: 15-minute grace window added so data starting at session start time is included
- **NL32 Date Parsing**: Date now read from `start_time` field instead of file metadata
- **Project Data Labels**: Improved Jinja filters and UI label clarity for project data views
### Fixed
- **Dev/Prod Separation**: Dev server now uses Docker Compose override; production deployment no longer affected by dev config
- **SLM Modal**: Bench/deploy toggle now correctly shown in SLM unit modal
- **Auto-Downloaded Files**: Files downloaded by scheduler now appear in project file listings
- **Duplicate Download**: Removed duplicate file download that occurred following a scheduled stop
- **SLMM Environment Variables**: `TCP_IDLE_TTL` and `TCP_MAX_AGE` now correctly passed to SLMM service via docker-compose
### Technical Details
- `session_label` and `period_type` stored on `monitoring_sessions` table (migration: `migrate_add_session_period_type.py`)
- `device_model` stored on `monitoring_sessions` table (migration: `migrate_add_session_device_model.py`)
- Upload endpoint: `POST /api/projects/{project_id}/nrl/{location_id}/upload-data`
- ZIP filename format: `{session_label}_{project_name}_report.xlsx` (label first)
### Migration Notes
Run the following migration scripts once per database before deploying:
```bash
python backend/migrate_rename_recording_to_monitoring_sessions.py
python backend/migrate_add_session_period_type.py
python backend/migrate_add_session_device_model.py
```
---
## [0.6.1] - 2026-02-16
### Added
@@ -445,6 +537,7 @@ No database migration required for v0.4.0. All new features use existing databas
- Photo management per unit
- Automated status categorization (OK/Pending/Missing)
[0.7.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.6.1...v0.7.0
[0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0

View File

@@ -1,5 +1,9 @@
FROM python:3.11-slim
# Build number for dev builds (injected via --build-arg)
ARG BUILD_NUMBER=0
ENV BUILD_NUMBER=${BUILD_NUMBER}
# Set working directory
WORKDIR /app

View File

@@ -1,4 +1,4 @@
# Terra-View v0.6.1
# Terra-View v0.8.0
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features
@@ -496,6 +496,21 @@ docker compose down -v
## Release Highlights
### v0.8.0 — 2026-03-18
- **Watcher Manager**: Admin page for monitoring field watcher agents with live status cards, log tails, and one-click update triggering
- **Watcher Status Fix**: Agent status now reflects heartbeat connectivity (missing if not heard from in >60 min) rather than unit-level data staleness
- **Live Refresh**: Watcher Manager surgically patches status, last-seen, and pending indicators every 30s without a full page reload
### v0.7.0 — 2026-03-07
- **Project Status Management**: On-hold and archived project states with automatic cancellation of pending actions
- **Manual SD Card Upload**: Upload offline NRL/SLM data directly from SD card (ZIP or multi-file); auto-creates monitoring sessions from `.rnh` metadata
- **Combined Report Wizard**: Multi-session Excel report generation with location grouping, period type filtering, and ZIP download
- **NL32 Support**: Report generator and web viewer now handle NL32 measurement data
- **Chart Preview**: Live chart preview in the report generator matching final output styling
- **Standalone SLM Mode**: SLMs can now be configured without a paired modem (direct IP)
- **Vibration Project Isolation**: Vibration project views no longer show SLM-specific tabs
- **MonitoringSession Rename**: `RecordingSession` renamed to `MonitoringSession` throughout; run migration before deploying
### v0.6.1 — 2026-02-16
- **One-Off Recording Schedules**: Schedule single recordings with specific start/end datetimes
- **Bidirectional Pairing Sync**: Device-modem pairing now updates both sides automatically
@@ -584,11 +599,17 @@ MIT
## Version
**Current: 0.6.1**One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
**Current: 0.8.0**Watcher Manager admin page, live agent status refresh, watcher connectivity-based status (2026-03-18)
Previous: 0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
Previous: 0.7.1 — Out-for-calibration status, reservation modal, migration fixes (2026-03-12)
Previous: 0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
0.7.0 — Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
0.6.1 — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)

View File

@@ -30,7 +30,11 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app
VERSION = "0.6.1"
VERSION = "0.8.0"
if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0":
VERSION = f"{VERSION}-{_build}"
app = FastAPI(
title="Seismo Fleet Manager",
description="Backend API for managing seismograph fleet status",
@@ -98,6 +102,9 @@ app.include_router(modem_dashboard.router)
from backend.routers import settings
app.include_router(settings.router)
from backend.routers import watcher_manager
app.include_router(watcher_manager.router)
# Projects system routers
app.include_router(projects.router)
app.include_router(project_locations.router)
@@ -655,6 +662,7 @@ async def devices_all_partial(request: Request):
"last_seen": unit_data.get("last", "Never"),
"deployed": True,
"retired": False,
"out_for_calibration": False,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
@@ -679,6 +687,32 @@ async def devices_all_partial(request: Request):
"last_seen": unit_data.get("last", "Never"),
"deployed": False,
"retired": False,
"out_for_calibration": False,
"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({
"id": unit_id,
"status": "Out for Calibration",
"age": "N/A",
"last_seen": "N/A",
"deployed": False,
"retired": False,
"out_for_calibration": True,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
@@ -703,6 +737,7 @@ async def devices_all_partial(request: Request):
"last_seen": "N/A",
"deployed": False,
"retired": True,
"out_for_calibration": False,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
@@ -727,6 +762,7 @@ async def devices_all_partial(request: Request):
"last_seen": "N/A",
"deployed": False,
"retired": False,
"out_for_calibration": False,
"ignored": True,
"note": unit_data.get("note", unit_data.get("reason", "")),
"device_type": unit_data.get("device_type", "unknown"),
@@ -744,15 +780,17 @@ async def devices_all_partial(request: Request):
# Sort by status category, then by ID
def sort_key(unit):
# Priority: deployed (active) -> benched -> retired -> ignored
# Priority: deployed (active) -> benched -> out_for_calibration -> retired -> ignored
if unit["deployed"]:
return (0, unit["id"])
elif not unit["retired"] and not unit["ignored"]:
elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]:
return (1, unit["id"])
elif unit["retired"]:
elif unit["out_for_calibration"]:
return (2, unit["id"])
else:
elif unit["retired"]:
return (3, unit["id"])
else:
return (4, unit["id"])
units_list.sort(key=sort_key)

View File

@@ -0,0 +1,54 @@
"""
Database Migration: Add out_for_calibration field to roster table
Changes:
- Adds out_for_calibration BOOLEAN column (default FALSE) to roster table
- Safe to run multiple times (idempotent)
- No data loss
Usage:
python backend/migrate_add_out_for_calibration.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def migrate():
db = SessionLocal()
try:
print("=" * 60)
print("Migration: Add out_for_calibration to roster")
print("=" * 60)
# Check if column already exists
result = db.execute(text("PRAGMA table_info(roster)")).fetchall()
columns = [row[1] for row in result]
if "out_for_calibration" in columns:
print("Column out_for_calibration already exists. Skipping.")
else:
db.execute(text("ALTER TABLE roster ADD COLUMN out_for_calibration BOOLEAN DEFAULT FALSE"))
db.commit()
print("Added out_for_calibration column to roster table.")
print("Migration complete.")
except Exception as e:
db.rollback()
print(f"Error: {e}")
raise
finally:
db.close()
if __name__ == "__main__":
migrate()

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
Migration: Add data_collection_mode column to projects table.
Values:
"remote" — units have modems; data pulled via FTP/scheduler automatically
"manual" — no modem; SD cards retrieved daily and uploaded by hand
All existing projects are backfilled to "manual" (safe conservative default).
Run once inside the Docker container:
docker exec terra-view python3 backend/migrate_add_project_data_collection_mode.py
"""
from pathlib import Path
DB_PATH = Path("data/seismo_fleet.db")
def migrate():
import sqlite3
if not DB_PATH.exists():
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# ── 1. Add column (idempotent) ───────────────────────────────────────────
cur.execute("PRAGMA table_info(projects)")
existing_cols = {row["name"] for row in cur.fetchall()}
if "data_collection_mode" not in existing_cols:
cur.execute("ALTER TABLE projects ADD COLUMN data_collection_mode TEXT DEFAULT 'manual'")
conn.commit()
print("✓ Added column data_collection_mode to projects")
else:
print("○ Column data_collection_mode already exists — skipping ALTER TABLE")
# ── 2. Backfill NULLs to 'manual' ────────────────────────────────────────
cur.execute("UPDATE projects SET data_collection_mode = 'manual' WHERE data_collection_mode IS NULL")
updated = cur.rowcount
conn.commit()
conn.close()
if updated:
print(f"✓ Backfilled {updated} project(s) to data_collection_mode='manual'.")
print("Migration complete.")
if __name__ == "__main__":
migrate()

View File

@@ -44,7 +44,7 @@ def migrate(db_path: str):
if __name__ == "__main__":
db_path = "./data/terra-view.db"
db_path = "./data/seismo_fleet.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Migration: Add device_model column to monitoring_sessions table.
Records which physical SLM model produced each session's data (e.g. "NL-43",
"NL-53", "NL-32"). Used by report generation to apply the correct parsing
logic without re-opening files to detect format.
Run once inside the Docker container:
docker exec terra-view python3 backend/migrate_add_session_device_model.py
Backfill strategy for existing rows:
1. If session.unit_id is set, use roster.slm_model for that unit.
2. Else, peek at the first .rnd file in the session: presence of the 'LAeq'
column header identifies AU2 / NL-32 format.
Sessions where neither hint is available remain NULL — the file-content
fallback in report code handles them transparently.
"""
import csv
import io
from pathlib import Path
DB_PATH = Path("data/seismo_fleet.db")
def _peek_first_row(abs_path: Path) -> dict:
"""Read only the header + first data row of an RND file. Very cheap."""
try:
with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
reader = csv.DictReader(f)
return next(reader, None) or {}
except Exception:
return {}
def _detect_model_from_rnd(abs_path: Path) -> str | None:
"""Return 'NL-32' if file uses AU2 column format, else None."""
row = _peek_first_row(abs_path)
if "LAeq" in row:
return "NL-32"
return None
def migrate():
import sqlite3
if not DB_PATH.exists():
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# ── 1. Add column (idempotent) ───────────────────────────────────────────
cur.execute("PRAGMA table_info(monitoring_sessions)")
existing_cols = {row["name"] for row in cur.fetchall()}
if "device_model" not in existing_cols:
cur.execute("ALTER TABLE monitoring_sessions ADD COLUMN device_model TEXT")
conn.commit()
print("✓ Added column device_model to monitoring_sessions")
else:
print("○ Column device_model already exists — skipping ALTER TABLE")
# ── 2. Backfill existing NULL rows ───────────────────────────────────────
cur.execute(
"SELECT id, unit_id FROM monitoring_sessions WHERE device_model IS NULL"
)
sessions = cur.fetchall()
print(f"Backfilling {len(sessions)} session(s) with device_model=NULL...")
updated = skipped = 0
for row in sessions:
session_id = row["id"]
unit_id = row["unit_id"]
device_model = None
# Strategy A: look up unit's slm_model from the roster
if unit_id:
cur.execute(
"SELECT slm_model FROM roster WHERE id = ?", (unit_id,)
)
unit_row = cur.fetchone()
if unit_row and unit_row["slm_model"]:
device_model = unit_row["slm_model"]
# Strategy B: detect from first .rnd file in the session
if device_model is None:
cur.execute(
"""SELECT file_path FROM data_files
WHERE session_id = ?
AND lower(file_path) LIKE '%.rnd'
LIMIT 1""",
(session_id,),
)
file_row = cur.fetchone()
if file_row:
abs_path = Path("data") / file_row["file_path"]
device_model = _detect_model_from_rnd(abs_path)
# None here means NL-43/NL-53 format (or unreadable file) —
# leave as NULL so the existing fallback applies.
if device_model:
cur.execute(
"UPDATE monitoring_sessions SET device_model = ? WHERE id = ?",
(device_model, session_id),
)
updated += 1
else:
skipped += 1
conn.commit()
conn.close()
print(f"✓ Backfilled {updated} session(s) with a device_model.")
if skipped:
print(
f" {skipped} session(s) left as NULL "
"(no unit link and no AU2 file hint — NL-43/NL-53 or unknown; "
"file-content detection applies at report time)."
)
print("Migration complete.")
if __name__ == "__main__":
migrate()

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""
Migration: Add session_label and period_type columns to monitoring_sessions.
session_label - user-editable display name, e.g. "NRL-1 Sun 2/23 Night"
period_type - one of: weekday_day | weekday_night | weekend_day | weekend_night
Auto-derived from started_at when NULL.
Period definitions (used in report stats table):
weekday_day Mon-Fri 07:00-22:00 -> Daytime (7AM-10PM)
weekday_night Mon-Fri 22:00-07:00 -> Nighttime (10PM-7AM)
weekend_day Sat-Sun 07:00-22:00 -> Daytime (7AM-10PM)
weekend_night Sat-Sun 22:00-07:00 -> Nighttime (10PM-7AM)
Run once inside the Docker container:
docker exec terra-view python3 backend/migrate_add_session_period_type.py
"""
from pathlib import Path
from datetime import datetime
DB_PATH = Path("data/seismo_fleet.db")
def _derive_period_type(started_at_str: str) -> str | None:
"""Derive period_type from a started_at ISO datetime string."""
if not started_at_str:
return None
try:
dt = datetime.fromisoformat(started_at_str)
except ValueError:
return None
is_weekend = dt.weekday() >= 5 # 5=Sat, 6=Sun
is_night = dt.hour >= 22 or dt.hour < 7
if is_weekend:
return "weekend_night" if is_night else "weekend_day"
else:
return "weekday_night" if is_night else "weekday_day"
def _build_label(started_at_str: str, location_name: str | None, period_type: str | None) -> str | None:
"""Build a human-readable session label."""
if not started_at_str:
return None
try:
dt = datetime.fromisoformat(started_at_str)
except ValueError:
return None
day_abbr = dt.strftime("%a") # Mon, Tue, Sun, etc.
date_str = dt.strftime("%-m/%-d") # 2/23
period_labels = {
"weekday_day": "Day",
"weekday_night": "Night",
"weekend_day": "Day",
"weekend_night": "Night",
}
period_str = period_labels.get(period_type or "", "")
parts = []
if location_name:
parts.append(location_name)
parts.append(f"{day_abbr} {date_str}")
if period_str:
parts.append(period_str)
return "".join(parts)
def migrate():
import sqlite3
if not DB_PATH.exists():
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# 1. Add columns (idempotent)
cur.execute("PRAGMA table_info(monitoring_sessions)")
existing_cols = {row["name"] for row in cur.fetchall()}
for col, typedef in [("session_label", "TEXT"), ("period_type", "TEXT")]:
if col not in existing_cols:
cur.execute(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {typedef}")
conn.commit()
print(f"✓ Added column {col} to monitoring_sessions")
else:
print(f"○ Column {col} already exists — skipping ALTER TABLE")
# 2. Backfill existing rows
cur.execute(
"""SELECT ms.id, ms.started_at, ms.location_id
FROM monitoring_sessions ms
WHERE ms.period_type IS NULL OR ms.session_label IS NULL"""
)
sessions = cur.fetchall()
print(f"Backfilling {len(sessions)} session(s)...")
updated = 0
for row in sessions:
session_id = row["id"]
started_at = row["started_at"]
location_id = row["location_id"]
# Look up location name
location_name = None
if location_id:
cur.execute("SELECT name FROM monitoring_locations WHERE id = ?", (location_id,))
loc_row = cur.fetchone()
if loc_row:
location_name = loc_row["name"]
period_type = _derive_period_type(started_at)
label = _build_label(started_at, location_name, period_type)
cur.execute(
"UPDATE monitoring_sessions SET period_type = ?, session_label = ? WHERE id = ?",
(period_type, label, session_id),
)
updated += 1
conn.commit()
conn.close()
print(f"✓ Backfilled {updated} session(s).")
print("Migration complete.")
if __name__ == "__main__":
migrate()

View File

@@ -42,7 +42,7 @@ def migrate(db_path: str):
if __name__ == "__main__":
db_path = "./data/terra-view.db"
db_path = "./data/seismo_fleet.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]

View File

@@ -32,6 +32,7 @@ class RosterUnit(Base):
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
deployed = Column(Boolean, default=True)
retired = Column(Boolean, default=False)
out_for_calibration = Column(Boolean, default=False)
note = Column(String, nullable=True)
project_id = Column(String, nullable=True)
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
@@ -65,6 +66,26 @@ class RosterUnit(Base):
slm_last_check = Column(DateTime, nullable=True) # Last communication check
class WatcherAgent(Base):
"""
Watcher agents: tracks the watcher processes (series3-watcher, thor-watcher)
that run on field machines and report unit heartbeats.
Updated on every heartbeat received from each source_id.
"""
__tablename__ = "watcher_agents"
id = Column(String, primary_key=True, index=True) # source_id (hostname)
source_type = Column(String, nullable=False) # series3_watcher | series4_watcher
version = Column(String, nullable=True) # e.g. "1.4.0"
last_seen = Column(DateTime, default=datetime.utcnow)
status = Column(String, nullable=False, default="unknown") # ok | pending | missing | error | unknown
ip_address = Column(String, nullable=True)
log_tail = Column(Text, nullable=True) # last N log lines (JSON array of strings)
update_pending = Column(Boolean, default=False) # set True to trigger remote update
update_version = Column(String, nullable=True) # target version to update to
class IgnoredUnit(Base):
"""
Ignored units: units that report but should be filtered out from unknown emitters.
@@ -157,6 +178,11 @@ class Project(Base):
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
# Data collection mode: how field data reaches Terra-View.
# "remote" — units have modems; data pulled via FTP/scheduler automatically
# "manual" — no modem; SD cards retrieved daily and uploaded by hand
data_collection_mode = Column(String, default="manual") # remote | manual
# Project metadata
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
site_address = Column(String, nullable=True)
@@ -257,12 +283,24 @@ class MonitoringSession(Base):
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads)
# Physical device model that produced this session's data (e.g. "NL-43", "NL-53", "NL-32").
# Null for older records; report code falls back to file-content detection when null.
device_model = Column(String, nullable=True)
session_type = Column(String, nullable=False) # sound | vibration
started_at = Column(DateTime, nullable=False)
stopped_at = Column(DateTime, nullable=True)
duration_seconds = Column(Integer, nullable=True)
status = Column(String, default="recording") # recording, completed, failed
# Human-readable label auto-derived from date/location, editable by user.
# e.g. "NRL-1 — Sun 2/23 — Night"
session_label = Column(String, nullable=True)
# Period classification for report stats columns.
# weekday_day | weekday_night | weekend_day | weekend_night
period_type = Column(String, nullable=True)
# Snapshot of device configuration at recording time
session_metadata = Column(Text, nullable=True) # JSON

View File

@@ -35,6 +35,39 @@ from backend.templates_config import templates
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
# ============================================================================
# Session period helpers
# ============================================================================
def _derive_period_type(dt: datetime) -> str:
"""
Classify a session start time into one of four period types.
Night = 22:0007:00, Day = 07:0022:00.
Weekend = Saturday (5) or Sunday (6).
"""
is_weekend = dt.weekday() >= 5
is_night = dt.hour >= 22 or dt.hour < 7
if is_weekend:
return "weekend_night" if is_night else "weekend_day"
return "weekday_night" if is_night else "weekday_day"
def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str:
"""Build a human-readable session label, e.g. 'NRL-1 — Sun 2/23 — Night'.
Uses started_at date as-is; user can correct period_type in the wizard.
"""
day_abbr = dt.strftime("%a")
date_str = f"{dt.month}/{dt.day}"
period_str = {
"weekday_day": "Day",
"weekday_night": "Night",
"weekend_day": "Day",
"weekend_night": "Night",
}.get(period_type, "")
parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p]
return "".join(parts)
# ============================================================================
# Monitoring Locations CRUD
# ============================================================================
@@ -634,6 +667,30 @@ async def upload_nrl_data(
if not file_entries:
raise HTTPException(status_code=400, detail="No usable files found in upload.")
# --- Step 1b: Filter to only relevant files ---
# Keep: .rnh (metadata) and measurement .rnd files
# NL-43 generates two .rnd types: _Leq_ (15-min averages, wanted) and _Lp_ (1-sec granular, skip)
# AU2 (NL-23/older Rion) generates a single Au2_####.rnd per session — always keep those
# Drop: _Lp_ .rnd, .xlsx, .mp3, and anything else
def _is_wanted(fname: str) -> bool:
n = fname.lower()
if n.endswith(".rnh"):
return True
if n.endswith(".rnd"):
if "_leq_" in n: # NL-43 Leq file
return True
if n.startswith("au2_"): # AU2 format (NL-23) — always Leq equivalent
return True
if "_lp" not in n and "_leq_" not in n:
# Unknown .rnd format — include it so we don't silently drop data
return True
return False
file_entries = [(fname, fbytes) for fname, fbytes in file_entries if _is_wanted(fname)]
if not file_entries:
raise HTTPException(status_code=400, detail="No usable .rnd or .rnh files found. Expected NL-43 _Leq_ files or AU2 format .rnd files.")
# --- Step 2: Find and parse .rnh metadata ---
rnh_meta = {}
for fname, fbytes in file_entries:
@@ -652,6 +709,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
session_id = str(uuid.uuid4())
monitoring_session = MonitoringSession(
id=session_id,
@@ -663,6 +723,8 @@ async def upload_nrl_data(
stopped_at=stopped_at,
duration_seconds=duration_seconds,
status="completed",
session_label=session_label,
period_type=period_type,
session_metadata=json.dumps({
"source": "manual_upload",
"store_name": store_name,

File diff suppressed because it is too large Load Diff

View File

@@ -146,6 +146,7 @@ async def add_roster_unit(
unit_type: str = Form("series3"),
deployed: str = Form(None),
retired: str = Form(None),
out_for_calibration: str = Form(None),
note: str = Form(""),
project_id: str = Form(None),
location: str = Form(None),
@@ -177,6 +178,7 @@ async def add_roster_unit(
# Convert boolean strings to actual booleans
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
# Convert port strings to integers
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
@@ -211,6 +213,7 @@ async def add_roster_unit(
unit_type=unit_type,
deployed=deployed_bool,
retired=retired_bool,
out_for_calibration=out_for_calibration_bool,
note=note,
project_id=project_id,
location=location,
@@ -463,6 +466,7 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"unit_type": unit.unit_type,
"deployed": unit.deployed,
"retired": unit.retired,
"out_for_calibration": unit.out_for_calibration or False,
"note": unit.note or "",
"project_id": unit.project_id or "",
"location": unit.location or "",
@@ -494,6 +498,7 @@ async def edit_roster_unit(
unit_type: str = Form("series3"),
deployed: str = Form(None),
retired: str = Form(None),
out_for_calibration: str = Form(None),
note: str = Form(""),
project_id: str = Form(None),
location: str = Form(None),
@@ -535,6 +540,7 @@ async def edit_roster_unit(
# Convert boolean strings to actual booleans
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
# Convert port strings to integers
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
@@ -564,12 +570,14 @@ async def edit_roster_unit(
old_note = unit.note
old_deployed = unit.deployed
old_retired = unit.retired
old_out_for_calibration = unit.out_for_calibration
# Update all fields
unit.device_type = device_type
unit.unit_type = unit_type
unit.deployed = deployed_bool
unit.retired = retired_bool
unit.out_for_calibration = out_for_calibration_bool
unit.note = note
unit.project_id = project_id
unit.location = location
@@ -677,6 +685,11 @@ async def edit_roster_unit(
old_status_text = "retired" if old_retired else "active"
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
if old_out_for_calibration != out_for_calibration_bool:
status_text = "out_for_calibration" if out_for_calibration_bool else "available"
old_status_text = "out_for_calibration" if old_out_for_calibration else "available"
record_history(db, unit_id, "calibration_status_change", "out_for_calibration", old_status_text, status_text, "manual")
# Handle cascade to paired device
cascaded_unit_id = None
if cascade_to_unit_id and cascade_to_unit_id.strip():

View File

@@ -28,7 +28,8 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
total = len(seismos)
deployed = sum(1 for s in seismos if s.deployed)
benched = sum(1 for s in seismos if not s.deployed)
benched = sum(1 for s in seismos if not s.deployed and not s.out_for_calibration)
out_for_calibration = sum(1 for s in seismos if s.out_for_calibration)
# Count modems assigned to deployed seismographs
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
@@ -41,6 +42,7 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
"total": total,
"deployed": deployed,
"benched": benched,
"out_for_calibration": out_for_calibration,
"with_modem": with_modem,
"without_modem": without_modem
}
@@ -77,7 +79,9 @@ async def get_seismo_units(
if status == "deployed":
query = query.filter(RosterUnit.deployed == True)
elif status == "benched":
query = query.filter(RosterUnit.deployed == False)
query = query.filter(RosterUnit.deployed == False, RosterUnit.out_for_calibration == False)
elif status == "out_for_calibration":
query = query.filter(RosterUnit.out_for_calibration == True)
# Apply modem filter
if modem == "with":

View File

@@ -0,0 +1,133 @@
"""
Watcher Manager — admin API for series3-watcher and thor-watcher agents.
Endpoints:
GET /api/admin/watchers — list all watcher agents
GET /api/admin/watchers/{agent_id} — get single agent detail
POST /api/admin/watchers/{agent_id}/trigger-update — flag agent for update
POST /api/admin/watchers/{agent_id}/clear-update — clear update flag
GET /api/admin/watchers/{agent_id}/update-check — polled by watcher on heartbeat
Page:
GET /admin/watchers — HTML admin page
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from typing import Optional
from backend.database import get_db
from backend.models import WatcherAgent
from backend.templates_config import templates
router = APIRouter(tags=["admin"])
# ── helpers ──────────────────────────────────────────────────────────────────
def _agent_to_dict(agent: WatcherAgent) -> dict:
last_seen = agent.last_seen
if last_seen:
now_utc = datetime.utcnow()
age_minutes = int((now_utc - last_seen).total_seconds() // 60)
if age_minutes > 60:
status = "missing"
else:
status = "ok"
else:
age_minutes = None
status = "missing"
return {
"id": agent.id,
"source_type": agent.source_type,
"version": agent.version,
"last_seen": last_seen.isoformat() if last_seen else None,
"age_minutes": age_minutes,
"status": status,
"ip_address": agent.ip_address,
"log_tail": agent.log_tail,
"update_pending": bool(agent.update_pending),
"update_version": agent.update_version,
}
# ── API routes ────────────────────────────────────────────────────────────────
@router.get("/api/admin/watchers")
def list_watchers(db: Session = Depends(get_db)):
agents = db.query(WatcherAgent).order_by(WatcherAgent.last_seen.desc()).all()
return [_agent_to_dict(a) for a in agents]
@router.get("/api/admin/watchers/{agent_id}")
def get_watcher(agent_id: str, db: Session = Depends(get_db)):
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Watcher agent not found")
return _agent_to_dict(agent)
class TriggerUpdateRequest(BaseModel):
version: Optional[str] = None # target version label (informational)
@router.post("/api/admin/watchers/{agent_id}/trigger-update")
def trigger_update(agent_id: str, body: TriggerUpdateRequest, db: Session = Depends(get_db)):
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Watcher agent not found")
agent.update_pending = True
agent.update_version = body.version
db.commit()
return {"ok": True, "agent_id": agent_id, "update_pending": True}
@router.post("/api/admin/watchers/{agent_id}/clear-update")
def clear_update(agent_id: str, db: Session = Depends(get_db)):
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Watcher agent not found")
agent.update_pending = False
agent.update_version = None
db.commit()
return {"ok": True, "agent_id": agent_id, "update_pending": False}
@router.get("/api/admin/watchers/{agent_id}/update-check")
def update_check(agent_id: str, db: Session = Depends(get_db)):
"""
Polled by watcher agents on each heartbeat cycle.
Returns update_available=True when an update has been triggered via the UI.
Automatically clears the flag after the watcher acknowledges it.
"""
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
if not agent:
return {"update_available": False}
pending = bool(agent.update_pending)
if pending:
# Clear the flag — the watcher will now self-update
agent.update_pending = False
db.commit()
return {
"update_available": pending,
"version": agent.update_version,
}
# ── HTML page ─────────────────────────────────────────────────────────────────
@router.get("/admin/watchers", response_class=HTMLResponse)
def admin_watchers_page(request: Request, db: Session = Depends(get_db)):
agents = db.query(WatcherAgent).order_by(WatcherAgent.last_seen.desc()).all()
agents_data = [_agent_to_dict(a) for a in agents]
return templates.TemplateResponse("admin_watchers.html", {
"request": request,
"agents": agents_data,
})

View File

@@ -5,7 +5,7 @@ from datetime import datetime
from typing import Optional, List
from backend.database import get_db
from backend.models import Emitter
from backend.models import Emitter, WatcherAgent
router = APIRouter()
@@ -107,6 +107,35 @@ def get_fleet_status(db: Session = Depends(get_db)):
emitters = db.query(Emitter).all()
return emitters
# ── Watcher agent upsert helper ───────────────────────────────────────────────
def _upsert_watcher_agent(db: Session, source_id: str, source_type: str,
version: str, ip_address: str, log_tail: str,
status: str) -> None:
"""Create or update the WatcherAgent row for a given source_id."""
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source_id).first()
if agent:
agent.source_type = source_type
agent.version = version
agent.last_seen = datetime.utcnow()
agent.status = status
if ip_address:
agent.ip_address = ip_address
if log_tail is not None:
agent.log_tail = log_tail
else:
agent = WatcherAgent(
id=source_id,
source_type=source_type,
version=version,
last_seen=datetime.utcnow(),
status=status,
ip_address=ip_address,
log_tail=log_tail,
)
db.add(agent)
# series3v1.1 Standardized Heartbeat Schema (multi-unit)
from fastapi import Request
@@ -120,6 +149,11 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
source = payload.get("source_id")
units = payload.get("units", [])
version = payload.get("version")
log_tail = payload.get("log_tail") # list of strings or None
import json as _json
log_tail_str = _json.dumps(log_tail) if log_tail is not None else None
client_ip = request.client.host if request.client else None
print("\n=== Series 3 Heartbeat ===")
print("Source:", source)
@@ -182,13 +216,27 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
results.append({"unit": uid, "status": status})
if source:
_upsert_watcher_agent(db, source, "series3_watcher", version,
client_ip, log_tail_str, "ok")
db.commit()
# Check if an update has been triggered for this agent
update_available = False
if source:
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first()
if agent and agent.update_pending:
update_available = True
agent.update_pending = False
db.commit()
return {
"message": "Heartbeat processed",
"source": source,
"units_processed": len(results),
"results": results
"results": results,
"update_available": update_available,
}
@@ -221,6 +269,11 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
source = payload.get("source", "series4_emitter")
units = payload.get("units", [])
version = payload.get("version")
log_tail = payload.get("log_tail")
import json as _json
log_tail_str = _json.dumps(log_tail) if log_tail is not None else None
client_ip = request.client.host if request.client else None
print("\n=== Series 4 Heartbeat ===")
print("Source:", source)
@@ -276,11 +329,25 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
results.append({"unit": uid, "status": status})
if source:
_upsert_watcher_agent(db, source, "series4_watcher", version,
client_ip, log_tail_str, "ok")
db.commit()
# Check if an update has been triggered for this agent
update_available = False
if source:
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first()
if agent and agent.update_pending:
update_available = True
agent.update_pending = False
db.commit()
return {
"message": "Heartbeat processed",
"source": source,
"units_processed": len(results),
"results": results
"results": results,
"update_available": update_available,
}

View File

@@ -80,6 +80,12 @@ def emit_status_snapshot():
age = "N/A"
last_seen = None
fname = ""
elif r.out_for_calibration:
# Out for calibration units get separated later
status = "Out for Calibration"
age = "N/A"
last_seen = None
fname = ""
else:
if e:
last_seen = ensure_utc(e.last_seen)
@@ -103,6 +109,7 @@ def emit_status_snapshot():
"deployed": r.deployed,
"note": r.note or "",
"retired": r.retired,
"out_for_calibration": r.out_for_calibration or False,
# 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,
@@ -133,6 +140,7 @@ def emit_status_snapshot():
"deployed": False, # default
"note": "",
"retired": False,
"out_for_calibration": False,
# Device type and type-specific fields (defaults for unknown units)
"device_type": "seismograph", # default
"last_calibrated": None,
@@ -179,12 +187,12 @@ def emit_status_snapshot():
# Separate buckets for UI
active_units = {
uid: u for uid, u in units.items()
if not u["retired"] and u["deployed"] and uid not in ignored
if not u["retired"] and not u["out_for_calibration"] and u["deployed"] and uid not in ignored
}
benched_units = {
uid: u for uid, u in units.items()
if not u["retired"] and not u["deployed"] and uid not in ignored
if not u["retired"] and not u["out_for_calibration"] and not u["deployed"] and uid not in ignored
}
retired_units = {
@@ -192,6 +200,11 @@ def emit_status_snapshot():
if u["retired"]
}
out_for_calibration_units = {
uid: u for uid, u in units.items()
if u["out_for_calibration"]
}
# Unknown units - emitters that aren't in the roster and aren't ignored
unknown_units = {
uid: u for uid, u in units.items()
@@ -204,12 +217,14 @@ def emit_status_snapshot():
"active": active_units,
"benched": benched_units,
"retired": retired_units,
"out_for_calibration": out_for_calibration_units,
"unknown": unknown_units,
"summary": {
"total": len(active_units) + len(benched_units),
"active": len(active_units),
"benched": len(benched_units),
"retired": len(retired_units),
"out_for_calibration": len(out_for_calibration_units),
"unknown": len(unknown_units),
# Status counts only for deployed units (active_units)
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),

View File

@@ -60,6 +60,19 @@ def jinja_same_date(dt1, dt2) -> bool:
return False
def jinja_log_tail_display(s):
"""Jinja filter: decode a JSON-encoded log tail array into a plain-text string."""
if not s:
return ""
try:
lines = _json.loads(s)
if isinstance(lines, list):
return "\n".join(str(l) for l in lines)
return str(s)
except Exception:
return str(s)
# Register Jinja filters and globals
templates.env.filters["local_datetime"] = jinja_local_datetime
templates.env.filters["local_time"] = jinja_local_time
@@ -68,3 +81,4 @@ templates.env.filters["fromjson"] = jinja_fromjson
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
templates.env.globals["get_user_timezone"] = get_user_timezone
templates.env.globals["same_date"] = jinja_same_date
templates.env.filters["log_tail_display"] = jinja_log_tail_display

View File

@@ -1,9 +1,7 @@
services:
# --- TERRA-VIEW PRODUCTION ---
terra-view:
build: .
container_name: terra-view
ports:
- "8001:8001"
volumes:
@@ -29,7 +27,6 @@ services:
build:
context: ../slmm
dockerfile: Dockerfile
container_name: slmm
network_mode: host
volumes:
- ../slmm/data:/app/data

37
migrate_watcher_agents.py Normal file
View File

@@ -0,0 +1,37 @@
"""
Migration: add watcher_agents table.
Safe to run multiple times (idempotent).
"""
import sqlite3
import os
DB_PATH = os.path.join(os.path.dirname(__file__), "data", "seismo.db")
def migrate():
con = sqlite3.connect(DB_PATH)
cur = con.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS watcher_agents (
id TEXT PRIMARY KEY,
source_type TEXT NOT NULL,
version TEXT,
last_seen DATETIME,
status TEXT NOT NULL DEFAULT 'unknown',
ip_address TEXT,
log_tail TEXT,
update_pending INTEGER NOT NULL DEFAULT 0,
update_version TEXT
)
""")
con.commit()
con.close()
print("Migration complete: watcher_agents table ready.")
if __name__ == "__main__":
migrate()

19
rebuild-dev.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
# Dev rebuild script — increments build number, rebuilds and restarts terra-view
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_FILE="$SCRIPT_DIR/build_number.txt"
# Read and increment build number
BUILD_NUMBER=$(cat "$BUILD_FILE" 2>/dev/null || echo "0")
BUILD_NUMBER=$((BUILD_NUMBER + 1))
echo "$BUILD_NUMBER" > "$BUILD_FILE"
echo "Building terra-view dev (build #$BUILD_NUMBER)..."
cd "$SCRIPT_DIR"
docker compose build --build-arg BUILD_NUMBER="$BUILD_NUMBER" terra-view
docker compose up -d terra-view
echo "Done — terra-view v0.6.1-$BUILD_NUMBER is running on :1001"

12
rebuild-prod.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
# Production rebuild script — rebuilds and restarts terra-view on :8001
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "Building terra-view production..."
docker compose -f docker-compose.yml build terra-view
docker compose -f docker-compose.yml up -d terra-view
echo "Done — terra-view production is running on :8001"

View File

@@ -8,7 +8,7 @@ import sys
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
DATABASE_URL = "sqlite:///data/sfm.db"
DATABASE_URL = "sqlite:///data/seismo_fleet.db"
def rename_unit(old_id: str, new_id: str):
"""

View File

@@ -0,0 +1,273 @@
{% extends "base.html" %}
{% block title %}Watcher Manager — Admin{% endblock %}
{% block content %}
<div class="mb-6">
<div class="flex items-center gap-3">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Watcher Manager</h1>
<span class="px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300 rounded-full">Admin</span>
</div>
<p class="text-gray-500 dark:text-gray-400 mt-1 text-sm">
Monitor and manage field watcher agents. Data updates on each heartbeat received.
</p>
</div>
<!-- Agent cards -->
<div id="agent-list" class="space-y-4">
{% if not agents %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow p-8 text-center">
<svg class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<p class="text-gray-500 dark:text-gray-400">No watcher agents have reported in yet.</p>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Once a watcher sends its first heartbeat it will appear here.</p>
</div>
{% endif %}
{% for agent in agents %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden" id="agent-{{ agent.id | replace(' ', '-') }}">
<!-- Card header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-slate-700">
<div class="flex items-center gap-3">
<!-- Status dot -->
{% if agent.status == 'ok' %}
<span class="status-dot inline-block w-3 h-3 rounded-full bg-green-500 flex-shrink-0"></span>
{% elif agent.status == 'pending' %}
<span class="status-dot inline-block w-3 h-3 rounded-full bg-yellow-400 flex-shrink-0"></span>
{% elif agent.status in ('missing', 'error') %}
<span class="status-dot inline-block w-3 h-3 rounded-full bg-red-500 flex-shrink-0"></span>
{% else %}
<span class="status-dot inline-block w-3 h-3 rounded-full bg-gray-400 flex-shrink-0"></span>
{% endif %}
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ agent.id }}</h2>
<div class="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 mt-0.5">
<span>{{ agent.source_type }}</span>
{% if agent.version %}
<span class="bg-gray-100 dark:bg-slate-700 px-1.5 py-0.5 rounded font-mono">v{{ agent.version }}</span>
{% endif %}
{% if agent.ip_address %}
<span>{{ agent.ip_address }}</span>
{% endif %}
</div>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Status badge -->
{% if agent.status == 'ok' %}
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">OK</span>
{% elif agent.status == 'pending' %}
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300">Pending</span>
{% elif agent.status == 'missing' %}
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">Missing</span>
{% elif agent.status == 'error' %}
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">Error</span>
{% else %}
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400">Unknown</span>
{% endif %}
<!-- Trigger Update button -->
<button
onclick="triggerUpdate('{{ agent.id }}')"
class="px-3 py-1.5 text-xs font-medium bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors"
id="btn-update-{{ agent.id | replace(' ', '-') }}"
>
Trigger Update
</button>
</div>
</div>
<!-- Meta row -->
<div class="px-6 py-3 bg-gray-50 dark:bg-slate-800 border-b border-gray-100 dark:border-slate-700 flex flex-wrap gap-6 text-sm">
<div>
<span class="text-gray-500 dark:text-gray-400">Last seen</span>
<span class="last-seen-value ml-2 font-medium text-gray-800 dark:text-gray-200">
{% if agent.last_seen %}
{{ agent.last_seen }}
{% if agent.age_minutes is not none %}
<span class="text-gray-400 dark:text-gray-500 font-normal">({{ agent.age_minutes }}m ago)</span>
{% endif %}
{% else %}
Never
{% endif %}
</span>
</div>
<div class="update-pending-indicator flex items-center gap-1.5 text-yellow-600 dark:text-yellow-400 {% if not agent.update_pending %}hidden{% endif %}">
<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"/>
</svg>
<span class="text-xs font-semibold">Update pending — will apply on next heartbeat</span>
</div>
</div>
<!-- Log tail -->
{% if agent.log_tail %}
<div class="px-6 py-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Log Tail</span>
<div class="flex items-center gap-3">
<button onclick="expandLog('{{ agent.id | replace(' ', '-') }}')" id="expand-{{ agent.id | replace(' ', '-') }}" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
Expand
</button>
<button onclick="toggleLog('{{ agent.id | replace(' ', '-') }}')" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
Toggle
</button>
</div>
</div>
<pre id="log-{{ agent.id | replace(' ', '-') }}" class="text-xs font-mono bg-gray-900 text-green-400 rounded-lg p-3 overflow-x-auto max-h-96 overflow-y-auto leading-relaxed hidden">{{ agent.log_tail | log_tail_display }}</pre>
</div>
{% else %}
<div class="px-6 py-4 text-xs text-gray-400 dark:text-gray-500 italic">No log data received yet.</div>
{% endif %}
</div>
{% endfor %}
</div>
<!-- Auto-refresh every 30s -->
<div class="mt-6 text-xs text-gray-400 dark:text-gray-600 text-center">
Auto-refreshes every 30 seconds &mdash; or <a href="/admin/watchers" class="underline hover:text-gray-600 dark:hover:text-gray-400">refresh now</a>
</div>
<script>
function triggerUpdate(agentId) {
if (!confirm('Trigger update for ' + agentId + '?\n\nThe watcher will self-update on its next heartbeat cycle.')) return;
const safeId = agentId.replace(/ /g, '-');
const btn = document.getElementById('btn-update-' + safeId);
btn.disabled = true;
btn.textContent = 'Sending...';
fetch('/api/admin/watchers/' + encodeURIComponent(agentId) + '/trigger-update', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({})
})
.then(r => r.json())
.then(data => {
if (data.ok) {
btn.textContent = 'Update Queued';
btn.classList.remove('bg-seismo-orange', 'hover:bg-orange-600');
btn.classList.add('bg-green-600');
// Show the pending indicator immediately without a reload
const card = document.getElementById('agent-' + safeId);
if (card) {
const indicator = card.querySelector('.update-pending-indicator');
if (indicator) indicator.classList.remove('hidden');
}
} else {
btn.textContent = 'Error';
btn.classList.add('bg-red-600');
btn.disabled = false;
}
})
.catch(() => {
btn.textContent = 'Failed';
btn.classList.add('bg-red-600');
btn.disabled = false;
});
}
function toggleLog(agentId) {
const el = document.getElementById('log-' + agentId);
if (el) el.classList.toggle('hidden');
}
function expandLog(agentId) {
const el = document.getElementById('log-' + agentId);
const btn = document.getElementById('expand-' + agentId);
if (!el) return;
el.classList.remove('hidden');
if (el.classList.contains('max-h-96')) {
el.classList.remove('max-h-96');
el.style.maxHeight = 'none';
if (btn) btn.textContent = 'Collapse';
} else {
el.classList.add('max-h-96');
el.style.maxHeight = '';
if (btn) btn.textContent = 'Expand';
}
}
// Status colors for dot and badge by status value
const STATUS_DOT = {
ok: 'bg-green-500',
pending: 'bg-yellow-400',
missing: 'bg-red-500',
error: 'bg-red-500',
};
const STATUS_BADGE_CLASSES = {
ok: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
missing: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
error: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
};
const STATUS_BADGE_DEFAULT = 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400';
const DOT_COLORS = ['bg-green-500', 'bg-yellow-400', 'bg-red-500', 'bg-gray-400'];
const BADGE_COLORS = [
'bg-green-100', 'text-green-700', 'dark:bg-green-900', 'dark:text-green-300',
'bg-yellow-100', 'text-yellow-700', 'dark:bg-yellow-900', 'dark:text-yellow-300',
'bg-red-100', 'text-red-700', 'dark:bg-red-900', 'dark:text-red-300',
'bg-gray-100', 'text-gray-600', 'dark:bg-slate-700', 'dark:text-gray-400',
];
function patchAgent(card, agent) {
// Status dot
const dot = card.querySelector('.status-dot');
if (dot) {
dot.classList.remove(...DOT_COLORS);
dot.classList.add(STATUS_DOT[agent.status] || 'bg-gray-400');
}
// Status badge
const badge = card.querySelector('.status-badge');
if (badge) {
badge.classList.remove(...BADGE_COLORS);
const label = agent.status ? agent.status.charAt(0).toUpperCase() + agent.status.slice(1) : 'Unknown';
badge.textContent = label === 'Ok' ? 'OK' : label;
const cls = STATUS_BADGE_CLASSES[agent.status] || STATUS_BADGE_DEFAULT;
badge.classList.add(...cls.split(' '));
}
// Last seen / age
const lastSeen = card.querySelector('.last-seen-value');
if (lastSeen) {
if (agent.last_seen) {
const age = agent.age_minutes != null
? ` <span class="text-gray-400 dark:text-gray-500 font-normal">(${agent.age_minutes}m ago)</span>`
: '';
lastSeen.innerHTML = agent.last_seen + age;
} else {
lastSeen.textContent = 'Never';
}
}
// Update pending indicator
const indicator = card.querySelector('.update-pending-indicator');
if (indicator) {
indicator.classList.toggle('hidden', !agent.update_pending);
}
}
function liveRefresh() {
fetch('/api/admin/watchers')
.then(r => r.json())
.then(agents => {
agents.forEach(agent => {
const safeId = agent.id.replace(/ /g, '-');
const card = document.getElementById('agent-' + safeId);
if (card) patchAgent(card, agent);
});
})
.catch(() => {}); // silently ignore fetch errors
}
setInterval(liveRefresh, 30000);
</script>
{% endblock %}

View File

@@ -0,0 +1,315 @@
{% extends "base.html" %}
{% block title %}Combined Report Preview - {{ project.name }}{% endblock %}
{% block content %}
<!-- jspreadsheet CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/jspreadsheet.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.css" />
<div class="min-h-screen bg-gray-100 dark:bg-slate-900">
<!-- Header -->
<div class="bg-white dark:bg-slate-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Combined Report Preview & Editor</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ location_data|length }} location{{ 's' if location_data|length != 1 else '' }}
{% if time_filter_desc %} | {{ time_filter_desc }}{% endif %}
| {{ total_rows }} total row{{ 's' if total_rows != 1 else '' }}
</p>
</div>
<div class="flex items-center gap-3">
<button onclick="downloadCombinedReport()" id="download-btn"
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 font-medium">
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Generate Reports (ZIP)
</button>
<a href="/api/projects/{{ project_id }}/combined-report-wizard"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm">
← Back to Config
</a>
</div>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 space-y-4">
<!-- Report Metadata -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Report Title</label>
<input type="text" id="edit-report-title" value="{{ report_title }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project Name</label>
<input type="text" id="edit-project-name" value="{{ project_name }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Client Name</label>
<input type="text" id="edit-client-name" value="{{ client_name }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
</div>
</div>
<!-- Location Tabs + Spreadsheet -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<!-- Tab Bar -->
<div class="border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
<div class="flex min-w-max" id="tab-bar">
{% for loc in location_data %}
<button onclick="switchTab({{ loop.index0 }})"
id="tab-btn-{{ loop.index0 }}"
class="tab-btn px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
{% if loop.first %}border-emerald-500 text-emerald-600 dark:text-emerald-400
{% else %}border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300{% endif %}">
{{ loc.location_name }}
<span class="ml-1.5 text-xs px-1.5 py-0.5 rounded-full
{% if loop.first %}bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400
{% else %}bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400{% endif %}"
id="tab-count-{{ loop.index0 }}">
{{ loc.filtered_count }}
</span>
</button>
{% endfor %}
</div>
</div>
<!-- Spreadsheet Panels -->
<div class="p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-base font-semibold text-gray-900 dark:text-white" id="active-tab-title">
{{ location_data[0].location_name if location_data else '' }}
</h3>
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>Right-click for options</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span>Double-click to edit</span>
</div>
</div>
{% for loc in location_data %}
<div id="panel-{{ loop.index0 }}" class="tab-panel {% if not loop.first %}hidden{% endif %} overflow-x-auto">
<div id="spreadsheet-{{ loop.index0 }}"></div>
</div>
{% endfor %}
</div>
</div>
<!-- Help -->
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300 mb-2">Editing Tips</h3>
<ul class="text-sm text-blue-700 dark:text-blue-400 list-disc list-inside space-y-1">
<li>Double-click any cell to edit its value</li>
<li>Use the Comments column to add notes about specific measurements</li>
<li>Right-click a row to insert or delete rows</li>
<li>Press Enter to confirm edits, Escape to cancel</li>
<li>Switch between location tabs to edit each location's data independently</li>
</ul>
</div>
</div>
</div>
<!-- jspreadsheet JS -->
<script src="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/index.min.js"></script>
<script>
const allLocationData = {{ locations_json | safe }};
const spreadsheets = {};
let activeTabIdx = 0;
const columnDef = [
{ title: 'Test #', width: 80, type: 'numeric' },
{ title: 'Date', width: 110, type: 'text' },
{ title: 'Time', width: 90, type: 'text' },
{ title: 'LAmax (dBA)', width: 110, type: 'numeric' },
{ title: 'LA01 (dBA)', width: 110, type: 'numeric' },
{ title: 'LA10 (dBA)', width: 110, type: 'numeric' },
{ title: 'Comments', width: 250, type: 'text' },
];
const jssOptions = {
columns: columnDef,
allowInsertRow: true,
allowDeleteRow: true,
allowInsertColumn: false,
allowDeleteColumn: false,
rowDrag: true,
columnSorting: true,
search: true,
pagination: 50,
paginationOptions: [25, 50, 100, 200],
defaultColWidth: 100,
minDimensions: [7, 1],
tableOverflow: true,
tableWidth: '100%',
contextMenu: function(instance, col, row, e) {
const items = [];
if (row !== null) {
items.push({
title: 'Insert row above',
onclick: function() { instance.insertRow(1, row, true); }
});
items.push({
title: 'Insert row below',
onclick: function() { instance.insertRow(1, row + 1, false); }
});
items.push({
title: 'Delete this row',
onclick: function() { instance.deleteRow(row); }
});
}
return items;
},
style: {
A: 'text-align: center;',
B: 'text-align: center;',
C: 'text-align: center;',
D: 'text-align: right;',
E: 'text-align: right;',
F: 'text-align: right;',
}
};
document.addEventListener('DOMContentLoaded', function() {
allLocationData.forEach(function(loc, idx) {
const el = document.getElementById('spreadsheet-' + idx);
if (!el) return;
const opts = Object.assign({}, jssOptions, { data: loc.spreadsheet_data });
spreadsheets[idx] = jspreadsheet(el, opts);
});
if (allLocationData.length > 0) {
switchTab(0);
}
});
function switchTab(idx) {
activeTabIdx = idx;
// Update panels
document.querySelectorAll('.tab-panel').forEach(function(panel, i) {
panel.classList.toggle('hidden', i !== idx);
});
// Update tab button styles
document.querySelectorAll('.tab-btn').forEach(function(btn, i) {
const countBadge = document.getElementById('tab-count-' + i);
if (i === idx) {
btn.classList.add('border-emerald-500', 'text-emerald-600', 'dark:text-emerald-400');
btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
if (countBadge) {
countBadge.classList.add('bg-emerald-100', 'text-emerald-700', 'dark:bg-emerald-900/40', 'dark:text-emerald-400');
countBadge.classList.remove('bg-gray-100', 'text-gray-500', 'dark:bg-gray-700', 'dark:text-gray-400');
}
} else {
btn.classList.remove('border-emerald-500', 'text-emerald-600', 'dark:text-emerald-400');
btn.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
if (countBadge) {
countBadge.classList.remove('bg-emerald-100', 'text-emerald-700', 'dark:bg-emerald-900/40', 'dark:text-emerald-400');
countBadge.classList.add('bg-gray-100', 'text-gray-500', 'dark:bg-gray-700', 'dark:text-gray-400');
}
}
});
// Update title
if (allLocationData[idx]) {
document.getElementById('active-tab-title').textContent = allLocationData[idx].location_name;
}
// Refresh jspreadsheet rendering after showing panel
if (spreadsheets[idx]) {
try { spreadsheets[idx].updateTable(); } catch(e) {}
}
}
async function downloadCombinedReport() {
const btn = document.getElementById('download-btn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Generating ZIP...';
try {
const locations = allLocationData.map(function(loc, idx) {
return {
session_id: loc.session_id || '',
session_label: loc.session_label || '',
period_type: loc.period_type || '',
started_at: loc.started_at || '',
location_name: loc.location_name,
spreadsheet_data: spreadsheets[idx] ? spreadsheets[idx].getData() : loc.spreadsheet_data,
};
});
const payload = {
report_title: document.getElementById('edit-report-title').value || 'Background Noise Study',
project_name: document.getElementById('edit-project-name').value || '',
client_name: document.getElementById('edit-client-name').value || '',
locations: locations,
};
const response = await fetch('/api/projects/{{ project_id }}/generate-combined-from-preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'combined_reports.zip';
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match) filename = match[1];
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} else {
const error = await response.json();
alert('Error generating report: ' + (error.detail || 'Unknown error'));
}
} catch (error) {
alert('Error generating report: ' + error.message);
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
</script>
<style>
/* Dark mode jspreadsheet styles */
.dark .jexcel { background-color: #1e293b; color: #e2e8f0; }
.dark .jexcel thead td { background-color: #334155 !important; color: #e2e8f0 !important; border-color: #475569 !important; }
.dark .jexcel tbody td { background-color: #1e293b; color: #e2e8f0; border-color: #475569; }
.dark .jexcel tbody td:hover { background-color: #334155; }
.dark .jexcel tbody tr:nth-child(even) td { background-color: #0f172a; }
.dark .jexcel_pagination { background-color: #1e293b; color: #e2e8f0; border-color: #475569; }
.dark .jexcel_pagination a { color: #e2e8f0; }
.dark .jexcel_search { background-color: #1e293b; color: #e2e8f0; border-color: #475569; }
.dark .jexcel_search input { background-color: #334155; color: #e2e8f0; border-color: #475569; }
.dark .jexcel_content { background-color: #1e293b; }
.dark .jexcel_contextmenu { background-color: #1e293b; border-color: #475569; }
.dark .jexcel_contextmenu a { color: #e2e8f0; }
.dark .jexcel_contextmenu a:hover { background-color: #334155; }
.jexcel_content { max-height: 600px; overflow: auto; }
</style>
{% endblock %}

View File

@@ -0,0 +1,393 @@
{% extends "base.html" %}
{% block title %}Combined Report Wizard - {{ project.name }}{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-100 dark:bg-slate-900">
<!-- Header -->
<div class="bg-white dark:bg-slate-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Combined Report Wizard</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ project.name }}</p>
</div>
<a href="/projects/{{ project_id }}"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm w-fit">
← Back to Project
</a>
</div>
</div>
</div>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
<!-- Report Settings Card -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Report Settings</h2>
<!-- Template Selection -->
<div class="flex items-end gap-2 mb-4">
<div class="flex-1">
<label for="template-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Load Template
</label>
<select id="template-select" onchange="applyTemplate()"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
<option value="">-- Select a template --</option>
</select>
</div>
<button type="button" onclick="saveAsTemplate()"
class="px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600"
title="Save current settings as template">
<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="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
</svg>
</button>
</div>
<!-- Report Title -->
<div class="mb-4">
<label for="report-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Report Title
</label>
<input type="text" id="report-title" value="Background Noise Study"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<!-- Project and Client -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="report-project" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Project Name
</label>
<input type="text" id="report-project" value="{{ project.name }}"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<div>
<label for="report-client" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Client Name
</label>
<input type="text" id="report-client" value="{{ project.client_name if project.client_name else '' }}"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
</div>
</div>
<!-- Sessions Card -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 overflow-hidden">
<div class="flex items-center justify-between mb-1">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
<div class="flex gap-3 text-sm">
<button type="button" onclick="selectAllSessions()" class="text-emerald-600 dark:text-emerald-400 hover:underline">Select All</button>
<button type="button" onclick="deselectAllSessions()" class="text-gray-500 dark:text-gray-400 hover:underline">Deselect All</button>
</div>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
<span id="selected-count">0</span> session(s) selected — each selected session becomes one sheet in the ZIP.
Change the period type per session to control how stats are bucketed (Day vs Night).
</p>
{% if locations %}
{% for loc in locations %}
{% set loc_name = loc.name %}
{% set sessions = loc.sessions %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg mb-3 overflow-hidden">
<!-- Location header / toggle -->
<button type="button"
onclick="toggleLocation('loc-{{ loop.index }}')"
class="w-full flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-slate-700/50 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-left">
<div class="flex items-center gap-3">
<svg id="chevron-loc-{{ loop.index }}" class="w-4 h-4 text-gray-400 transition-transform" 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>
<span class="font-medium text-gray-900 dark:text-white text-sm">{{ loc_name }}</span>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ sessions|length }} session{{ 's' if sessions|length != 1 else '' }}</span>
</div>
<div class="flex items-center gap-3 text-xs" onclick="event.stopPropagation()">
<button type="button" onclick="selectLocation('loc-{{ loop.index }}')"
class="text-emerald-600 dark:text-emerald-400 hover:underline">All</button>
<button type="button" onclick="deselectLocation('loc-{{ loop.index }}')"
class="text-gray-400 hover:underline">None</button>
</div>
</button>
<!-- Session rows -->
<div id="loc-{{ loop.index }}" class="divide-y divide-gray-100 dark:divide-gray-700/50">
{% for s in sessions %}
{% set pt_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',
'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
} %}
{% set pt_labels = {
'weekday_day': 'Weekday Day',
'weekday_night': 'Weekday Night',
'weekend_day': 'Weekend Day',
'weekend_night': 'Weekend Night',
} %}
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-slate-700/30 transition-colors">
<!-- Checkbox -->
<input type="checkbox"
class="session-cb loc-{{ loop.index }}-cb h-4 w-4 text-emerald-600 border-gray-300 dark:border-gray-600 rounded focus:ring-emerald-500 shrink-0"
value="{{ s.session_id }}"
checked
onchange="updateSelectionStats()">
<!-- Date/day info -->
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ s.day_of_week }} {{ s.date_display }}
</span>
{% if s.session_label %}
<span class="text-xs text-gray-400 dark:text-gray-500 truncate">{{ s.session_label }}</span>
{% endif %}
{% if s.status == 'recording' %}
<span class="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
<span class="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>Recording
</span>
{% endif %}
</div>
<div class="flex items-center gap-3 mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{% if s.started_at %}
<span>{{ s.started_at }}</span>
{% endif %}
{% if s.duration_h is not none %}
<span>{{ s.duration_h }}h {{ s.duration_m }}m</span>
{% endif %}
</div>
</div>
<!-- Period type dropdown -->
<div class="relative shrink-0" id="wiz-period-wrap-{{ s.session_id }}">
<button type="button"
onclick="toggleWizPeriodMenu('{{ s.session_id }}')"
id="wiz-period-badge-{{ s.session_id }}"
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ pt_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">
<span id="wiz-period-label-{{ s.session_id }}">{{ pt_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="wiz-period-menu-{{ s.session_id }}"
class="hidden absolute right-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 type="button"
onclick="setWizPeriodType('{{ s.session_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 %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-10 text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
</svg>
<p>No monitoring sessions found.</p>
<p class="text-sm mt-1">Upload data files to create sessions first.</p>
</div>
{% endif %}
</div>
<!-- Footer Buttons -->
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 pb-6">
<a href="/projects/{{ project_id }}"
class="w-full sm:w-auto px-6 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors text-center text-sm font-medium">
Cancel
</a>
<button type="button" onclick="gotoPreview()" id="preview-btn"
{% if not locations %}disabled{% endif %}
class="w-full sm:w-auto px-6 py-2.5 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50 disabled: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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
Preview & Edit →
</button>
</div>
</div>
</div>
<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',
weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
};
const PERIOD_LABELS = {
weekday_day: 'Weekday Day',
weekday_night: 'Weekday Night',
weekend_day: 'Weekend Day',
weekend_night: 'Weekend Night',
};
const ALL_PERIOD_BADGE_CLASSES = [
'bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400',
...new Set(Object.values(PERIOD_COLORS).flatMap(s => s.split(' ')))
];
// ── Location accordion ────────────────────────────────────────────
function toggleLocation(locId) {
const body = document.getElementById(locId);
const chevron = document.getElementById('chevron-' + locId);
body.classList.toggle('hidden');
chevron.style.transform = body.classList.contains('hidden') ? 'rotate(-90deg)' : '';
}
function selectLocation(locId) {
document.querySelectorAll('.' + locId + '-cb').forEach(cb => cb.checked = true);
updateSelectionStats();
}
function deselectLocation(locId) {
document.querySelectorAll('.' + locId + '-cb').forEach(cb => cb.checked = false);
updateSelectionStats();
}
function selectAllSessions() {
document.querySelectorAll('.session-cb').forEach(cb => cb.checked = true);
updateSelectionStats();
}
function deselectAllSessions() {
document.querySelectorAll('.session-cb').forEach(cb => cb.checked = false);
updateSelectionStats();
}
function updateSelectionStats() {
const count = document.querySelectorAll('.session-cb:checked').length;
document.getElementById('selected-count').textContent = count;
document.getElementById('preview-btn').disabled = count === 0;
}
// ── Period type dropdown (wizard) ─────────────────────────────────
function toggleWizPeriodMenu(sessionId) {
const menu = document.getElementById('wiz-period-menu-' + sessionId);
document.querySelectorAll('[id^="wiz-period-menu-"]').forEach(m => {
if (m.id !== 'wiz-period-menu-' + sessionId) m.classList.add('hidden');
});
menu.classList.toggle('hidden');
}
document.addEventListener('click', function(e) {
if (!e.target.closest('[id^="wiz-period-wrap-"]')) {
document.querySelectorAll('[id^="wiz-period-menu-"]').forEach(m => m.classList.add('hidden'));
}
});
async function setWizPeriodType(sessionId, periodType) {
document.getElementById('wiz-period-menu-' + sessionId).classList.add('hidden');
const badge = document.getElementById('wiz-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_PERIOD_BADGE_CLASSES.forEach(c => badge.classList.remove(c));
const colorStr = PERIOD_COLORS[periodType] || 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400';
badge.classList.add(...colorStr.split(' ').filter(Boolean));
document.getElementById('wiz-period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
} catch(err) {
alert('Failed to update period type: ' + err.message);
} finally {
badge.disabled = false;
}
}
// ── Template management ───────────────────────────────────────────
let reportTemplates = [];
async function loadTemplates() {
try {
const resp = await fetch('/api/report-templates?project_id=' + PROJECT_ID);
if (resp.ok) {
reportTemplates = await resp.json();
populateTemplateDropdown();
}
} catch(e) { console.error('Error loading templates:', e); }
}
function populateTemplateDropdown() {
const select = document.getElementById('template-select');
if (!select) return;
select.innerHTML = '<option value="">-- Select a template --</option>';
reportTemplates.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.name;
opt.dataset.config = JSON.stringify(t);
select.appendChild(opt);
});
}
function applyTemplate() {
const select = document.getElementById('template-select');
const opt = select.options[select.selectedIndex];
if (!opt.value) return;
const t = JSON.parse(opt.dataset.config);
if (t.report_title) document.getElementById('report-title').value = t.report_title;
}
async function saveAsTemplate() {
const name = prompt('Enter a name for this template:');
if (!name) return;
const data = {
name,
project_id: PROJECT_ID,
report_title: document.getElementById('report-title').value || 'Background Noise Study',
};
try {
const resp = await fetch('/api/report-templates', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
});
if (resp.ok) { alert('Template saved!'); loadTemplates(); }
else alert('Failed to save template');
} catch(e) { alert('Error: ' + e.message); }
}
// ── Navigate to preview ───────────────────────────────────────────
function gotoPreview() {
const checked = Array.from(document.querySelectorAll('.session-cb:checked')).map(cb => cb.value);
if (checked.length === 0) {
alert('Please select at least one session.');
return;
}
const params = new URLSearchParams({
report_title: document.getElementById('report-title').value || 'Background Noise Study',
project_name: document.getElementById('report-project').value || '',
client_name: document.getElementById('report-client').value || '',
selected_sessions: checked.join(','),
});
window.location.href = `/api/projects/${PROJECT_ID}/combined-report-preview?${params.toString()}`;
}
// ── Init ─────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', function() {
updateSelectionStats();
loadTemplates();
});
</script>
{% endblock %}

View File

@@ -397,13 +397,14 @@
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto" onclick="event.stopPropagation()">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">New Reservation</h2>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white" id="modal-title">New Reservation</h2>
<button onclick="closeReservationModal()" 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"/>
</svg>
</button>
</div>
<input type="hidden" id="editing-reservation-id" value="">
<form id="reservation-form" onsubmit="submitReservation(event)">
<!-- Name -->
@@ -522,7 +523,7 @@
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
Cancel
</button>
<button type="submit"
<button type="submit" id="modal-submit-btn"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Create Reservation
</button>
@@ -600,27 +601,83 @@ function closeDayPanel() {
let reservationModeActive = false;
function openReservationModal() {
// Reset to "create" mode
document.getElementById('modal-title').textContent = 'New Reservation';
document.getElementById('modal-submit-btn').textContent = 'Create Reservation';
document.getElementById('editing-reservation-id').value = '';
document.getElementById('reservation-form').reset();
document.getElementById('reservation-modal').classList.remove('hidden');
reservationModeActive = true;
// Show reservation legend, hide main legend
document.getElementById('main-legend').classList.add('md:hidden');
document.getElementById('main-legend').classList.remove('md:flex');
document.getElementById('reservation-legend').classList.remove('md:hidden');
document.getElementById('reservation-legend').classList.add('md:flex');
// Trigger availability update
updateCalendarAvailability();
}
async function editReservation(id) {
try {
const response = await fetch(`/api/fleet-calendar/reservations/${id}`);
if (!response.ok) { alert('Failed to load reservation'); return; }
const res = await response.json();
// Switch modal to "edit" mode
document.getElementById('modal-title').textContent = 'Edit Reservation';
document.getElementById('modal-submit-btn').textContent = 'Save Changes';
document.getElementById('editing-reservation-id').value = id;
// Populate fields
const form = document.getElementById('reservation-form');
form.querySelector('input[name="name"]').value = res.name;
form.querySelector('select[name="project_id"]').value = res.project_id || '';
form.querySelector('input[name="start_date"]').value = res.start_date;
form.querySelector('textarea[name="notes"]').value = res.notes || '';
// Color radio
const colorRadio = form.querySelector(`input[name="color"][value="${res.color}"]`);
if (colorRadio) colorRadio.checked = true;
// Assignment type
const atRadio = form.querySelector(`input[name="assignment_type"][value="${res.assignment_type}"]`);
if (atRadio) { atRadio.checked = true; toggleAssignmentType(res.assignment_type); }
if (res.assignment_type === 'quantity') {
form.querySelector('input[name="quantity_needed"]').value = res.quantity_needed || 1;
}
// End date / TBD
const tbdCheckbox = document.getElementById('end_date_tbd');
if (res.end_date_tbd) {
tbdCheckbox.checked = true;
form.querySelector('input[name="estimated_end_date"]').value = res.estimated_end_date || '';
} else {
tbdCheckbox.checked = false;
document.getElementById('end_date_input').value = res.end_date || '';
}
toggleEndDateTBD();
document.getElementById('reservation-modal').classList.remove('hidden');
reservationModeActive = true;
document.getElementById('main-legend').classList.add('md:hidden');
document.getElementById('main-legend').classList.remove('md:flex');
document.getElementById('reservation-legend').classList.remove('md:hidden');
document.getElementById('reservation-legend').classList.add('md:flex');
updateCalendarAvailability();
} catch (error) {
console.error('Error loading reservation:', error);
alert('Error loading reservation');
}
}
function closeReservationModal() {
document.getElementById('reservation-modal').classList.add('hidden');
document.getElementById('reservation-form').reset();
document.getElementById('editing-reservation-id').value = '';
reservationModeActive = false;
// Restore main legend
document.getElementById('main-legend').classList.remove('md:hidden');
document.getElementById('main-legend').classList.add('md:flex');
document.getElementById('reservation-legend').classList.add('md:hidden');
document.getElementById('reservation-legend').classList.remove('md:flex');
// Reset calendar colors
resetCalendarColors();
}
@@ -752,6 +809,7 @@ async function submitReservation(event) {
const form = event.target;
const formData = new FormData(form);
const endDateTbd = formData.get('end_date_tbd') === 'on';
const editingId = document.getElementById('editing-reservation-id').value;
const data = {
name: formData.get('name'),
@@ -766,7 +824,6 @@ async function submitReservation(event) {
notes: formData.get('notes') || null
};
// Validate: need either end_date or TBD checked
if (!data.end_date && !data.end_date_tbd) {
alert('Please enter an end date or check "TBD / Ongoing"');
return;
@@ -778,9 +835,15 @@ async function submitReservation(event) {
data.unit_ids = formData.getAll('unit_ids');
}
const isEdit = editingId !== '';
const url = isEdit
? `/api/fleet-calendar/reservations/${editingId}`
: '/api/fleet-calendar/reservations';
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch('/api/fleet-calendar/reservations', {
method: 'POST',
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
@@ -789,14 +852,13 @@ async function submitReservation(event) {
if (result.success) {
closeReservationModal();
// Reload the page to refresh calendar
window.location.reload();
} else {
alert('Error creating reservation: ' + (result.detail || 'Unknown error'));
alert(`Error ${isEdit ? 'saving' : 'creating'} reservation: ` + (result.detail || 'Unknown error'));
}
} catch (error) {
console.error('Error:', error);
alert('Error creating reservation');
alert(`Error ${isEdit ? 'saving' : 'creating'} reservation`);
}
}

View File

@@ -385,16 +385,27 @@
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
hover:file:bg-seismo-navy file:cursor-pointer" />
<div class="flex items-center gap-3 mt-3">
<button onclick="submitUpload()"
<button id="upload-btn" onclick="submitUpload()"
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
Import Files
</button>
<button onclick="toggleUploadPanel()"
<button id="upload-cancel-btn" onclick="toggleUploadPanel()"
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-status" class="text-sm hidden"></span>
</div>
<!-- Progress bar (hidden until upload starts) -->
<div id="upload-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-progress-label">Uploading…</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div id="upload-progress-bar"
class="bg-green-500 h-2 rounded-full transition-all duration-300"
style="width: 0%"></div>
</div>
</div>
</div>
<div id="data-files-list"
@@ -629,57 +640,105 @@ function toggleUploadPanel() {
const panel = document.getElementById('upload-panel');
const status = document.getElementById('upload-status');
panel.classList.toggle('hidden');
// Reset status when reopening
// Reset state when reopening
if (!panel.classList.contains('hidden')) {
status.textContent = '';
status.className = 'text-sm hidden';
document.getElementById('upload-input').value = '';
document.getElementById('upload-progress-wrap').classList.add('hidden');
document.getElementById('upload-progress-bar').style.width = '0%';
}
}
async function submitUpload() {
function submitUpload() {
const input = document.getElementById('upload-input');
const status = document.getElementById('upload-status');
const btn = document.getElementById('upload-btn');
const cancelBtn = document.getElementById('upload-cancel-btn');
const progressWrap = document.getElementById('upload-progress-wrap');
const progressBar = document.getElementById('upload-progress-bar');
const progressLabel = document.getElementById('upload-progress-label');
if (!input.files.length) {
alert('Please select files to upload.');
return;
}
const fileCount = input.files.length;
const formData = new FormData();
for (const file of input.files) {
formData.append('files', file);
}
status.textContent = 'Uploading\u2026';
status.className = 'text-sm text-gray-500';
// Disable controls and show progress bar
btn.disabled = true;
btn.textContent = 'Uploading\u2026';
btn.classList.add('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = true;
cancelBtn.classList.add('opacity-40', 'cursor-not-allowed');
status.className = 'text-sm hidden';
progressWrap.classList.remove('hidden');
progressBar.style.width = '0%';
progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026`;
try {
const response = await fetch(
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
{ method: 'POST', body: formData }
);
const data = await response.json();
const xhr = new XMLHttpRequest();
if (response.ok) {
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
if (data.leq_files || data.lp_files) {
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = pct + '%';
progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026 ${pct}%`;
}
});
xhr.upload.addEventListener('load', () => {
progressBar.style.width = '100%';
progressLabel.textContent = 'Processing files on server\u2026';
});
xhr.addEventListener('load', () => {
progressWrap.classList.add('hidden');
btn.disabled = false;
btn.textContent = 'Import Files';
btn.classList.remove('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = false;
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
try {
const data = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300) {
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
if (data.leq_files || data.lp_files) {
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
}
if (data.store_name) parts.push(`\u2014 ${data.store_name}`);
status.textContent = parts.join(' ');
status.className = 'text-sm text-green-600 dark:text-green-400';
input.value = '';
htmx.trigger(document.getElementById('data-files-list'), 'load');
} else {
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
status.className = 'text-sm text-red-600 dark:text-red-400';
}
if (data.store_name) parts.push(`\u2014 ${data.store_name}`);
status.textContent = parts.join(' ');
status.className = 'text-sm text-green-600 dark:text-green-400';
input.value = '';
// Refresh the file list
htmx.trigger(document.getElementById('data-files-list'), 'load');
} else {
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
} catch {
status.textContent = 'Error: Unexpected server response';
status.className = 'text-sm text-red-600 dark:text-red-400';
}
} catch (err) {
status.textContent = `Error: ${err.message}`;
});
xhr.addEventListener('error', () => {
progressWrap.classList.add('hidden');
btn.disabled = false;
btn.textContent = 'Import Files';
btn.classList.remove('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = false;
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
status.textContent = 'Error: Network error during upload';
status.className = 'text-sm text-red-600 dark:text-red-400';
}
});
xhr.open('POST', `/api/projects/${projectId}/nrl/${locationId}/upload-data`);
xhr.send(formData);
}
</script>
{% endblock %}

View File

@@ -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.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{% else %}benched{% endif %}"
data-health="{{ unit.status }}"
data-id="{{ unit.id }}"
data-type="{{ unit.device_type }}"
@@ -60,7 +60,9 @@
data-note="{{ unit.note if unit.note else '' }}">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-2">
{% if not unit.deployed %}
{% if unit.out_for_calibration %}
<span class="w-3 h-3 rounded-full bg-purple-500" title="Out for Calibration"></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' %}
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
@@ -72,6 +74,8 @@
{% if unit.deployed %}
<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>
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
{% endif %}
@@ -203,14 +207,16 @@
<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.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{% else %}benched{% endif %}"
data-health="{{ unit.status }}"
data-unit-id="{{ unit.id }}"
data-age="{{ unit.age }}">
<!-- Header: Status Dot + Unit ID + Status Badge -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
{% if not unit.deployed %}
{% if unit.out_for_calibration %}
<span class="w-4 h-4 rounded-full bg-purple-500" title="Out for Calibration"></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' %}
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
@@ -224,12 +230,13 @@
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
</div>
<span class="px-3 py-1 rounded-full text-xs font-medium
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
{% if unit.out_for_calibration %}bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-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.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
{% if unit.out_for_calibration %}Out for Cal{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
</span>
</div>
@@ -279,6 +286,10 @@
<span class="text-xs text-blue-600 dark:text-blue-400">
⚡ Deployed
</span>
{% elif unit.out_for_calibration %}
<span class="text-xs text-purple-600 dark:text-purple-400">
🔧 Out for Calibration
</span>
{% else %}
<span class="text-xs text-gray-500 dark:text-gray-500">
📦 Benched

View File

@@ -87,10 +87,7 @@ async function deleteReservation(id, name) {
}
}
function editReservation(id) {
// For now, just show alert - can implement edit modal later
alert('Edit functionality coming soon. For now, delete and recreate the reservation.');
}
// editReservation is defined in fleet_calendar.html
</script>
{% else %}
<div class="text-center py-8">

View File

@@ -75,6 +75,32 @@ Include this modal in pages that use the project picker.
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Data Collection <span class="text-red-500">*</span>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-start gap-3 p-3 border-2 border-seismo-orange bg-orange-50 dark:bg-orange-900/20 rounded-lg cursor-pointer" id="qcp-mode-manual-label">
<input type="radio" name="data_collection_mode" value="manual" checked
onchange="qcpUpdateModeStyles()"
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 border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer" id="qcp-mode-remote-label">
<input type="radio" name="data_collection_mode" value="remote"
onchange="qcpUpdateModeStyles()"
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 id="qcp-error" class="hidden p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm">
</div>
@@ -98,6 +124,24 @@ Include this modal in pages that use the project picker.
</div>
<script>
function qcpUpdateModeStyles() {
const manualChecked = document.querySelector('input[name="data_collection_mode"][value="manual"]')?.checked;
const manualLabel = document.getElementById('qcp-mode-manual-label');
const remoteLabel = document.getElementById('qcp-mode-remote-label');
if (!manualLabel || !remoteLabel) return;
if (manualChecked) {
manualLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
manualLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
remoteLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
remoteLabel.classList.add('border-gray-300', 'dark:border-gray-600');
} else {
remoteLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
remoteLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
manualLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
manualLabel.classList.add('border-gray-300', 'dark:border-gray-600');
}
}
// Quick create project modal functions
if (typeof openCreateProjectModal === 'undefined') {
function openCreateProjectModal(searchQuery, pickerId = '') {
@@ -113,6 +157,7 @@ if (typeof openCreateProjectModal === 'undefined') {
// Reset form
document.getElementById('quickCreateProjectForm').reset();
qcpUpdateModeStyles();
if (errorDiv) errorDiv.classList.add('hidden');
// Try to parse the search query intelligently

View File

@@ -12,12 +12,27 @@
{% if project_type %}
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
{% endif %}
{% 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">
<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>
Remote
</span>
{% else %}
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
<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="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
</svg>
Manual
</span>
{% endif %}
</div>
</div>
<!-- Project Actions -->
<div class="flex items-center gap-3">
{% if project_type and project_type.id == 'sound_monitoring' %}
<a href="/api/projects/{{ project.id }}/generate-combined-report"
<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">
<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>

View File

@@ -1,79 +1,149 @@
<!-- Monitoring Sessions List -->
{% if sessions %}
<div class="space-y-4">
<div class="space-y-3">
{% for item in sessions %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<div class="flex items-start justify-between gap-3">
{% set s = item.session %}
{% set loc = item.location %}
{% set unit = item.unit %}
{# Period display maps #}
{% set period_labels = {
'weekday_day': 'Weekday Day',
'weekday_night': 'Weekday Night',
'weekend_day': 'Weekend Day',
'weekend_night': 'Weekend Night',
} %}
{% set 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',
'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
} %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-slate-800 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
id="session-card-{{ s.id }}">
<div class="flex items-start justify-between gap-3 p-4 pb-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3 mb-2">
<h4 class="font-semibold text-gray-900 dark:text-white">
Session {{ item.session.id[:8] }}...
</h4>
{% if item.session.status == 'recording' %}
<span class="px-2 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">
<span class="w-2 h-2 bg-red-500 rounded-full mr-1.5 animate-pulse"></span>
Recording
</span>
{% elif item.session.status == 'completed' %}
<span class="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">
Completed
</span>
{% elif item.session.status == 'paused' %}
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
Paused
</span>
{% elif item.session.status == 'failed' %}
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
Failed
<!-- Label + badges -->
<div class="flex flex-wrap items-center gap-2 mb-2">
<span id="label-display-{{ s.id }}"
class="font-semibold text-gray-900 dark:text-white text-sm cursor-pointer hover:text-seismo-orange"
title="Click to edit label"
onclick="startEditLabel('{{ s.id }}')">
{{ s.session_label or ('Session ' + s.id[:8] + '…') }}
</span>
<input id="label-input-{{ s.id }}"
class="hidden text-sm font-semibold bg-transparent border-b border-seismo-orange text-gray-900 dark:text-white focus:outline-none min-w-[180px]"
value="{{ s.session_label or '' }}"
onblur="saveLabel('{{ s.id }}')"
onkeydown="if(event.key==='Enter'){saveLabel('{{ s.id }}');}if(event.key==='Escape'){cancelEditLabel('{{ s.id }}');}">
{% if s.status == 'recording' %}
<span class="px-2 py-0.5 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-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>Recording
</span>
{% elif s.status == 'completed' %}
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Completed</span>
{% elif s.status == 'failed' %}
<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) -->
<div class="relative" id="period-wrap-{{ s.id }}">
<button onclick="togglePeriodMenu('{{ 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">
<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 %}
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
{% if item.unit %}
<div>
<span class="text-xs text-gray-500 dark:text-gray-500">Unit:</span>
<a href="/slm/{{ item.unit.id }}?from_project={{ project_id }}" class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
{{ item.unit.id }}
</a>
<!-- Info grid -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
{% if loc %}
<div class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ loc.name }}</span>
</div>
{% endif %}
<div>
<span class="text-xs text-gray-500">Started:</span>
<span class="ml-1">{{ item.session.started_at|local_datetime if item.session.started_at else 'N/A' }}</span>
</div>
{% if item.session.stopped_at %}
<div>
<span class="text-xs text-gray-500">Ended:</span>
<span class="ml-1">{{ item.session.stopped_at|local_datetime }}</span>
{% if s.started_at %}
<div class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" 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>
<span>{{ s.started_at|local_datetime }}</span>
</div>
{% endif %}
{% if item.session.duration_seconds %}
<div>
<span class="text-xs text-gray-500">Duration:</span>
<span class="ml-1">{{ (item.session.duration_seconds // 3600) }}h {{ ((item.session.duration_seconds % 3600) // 60) }}m</span>
{% if s.stopped_at %}
<div class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span>Ended {{ s.stopped_at|local_datetime }}</span>
</div>
{% endif %}
{% if s.duration_seconds %}
<div class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" 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>{{ (s.duration_seconds // 3600) }}h {{ ((s.duration_seconds % 3600) // 60) }}m</span>
</div>
{% endif %}
{% if unit %}
<div class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2v-4M9 21H5a2 2 0 01-2-2v-4m0 0h18"></path>
</svg>
<a href="/slm/{{ unit.id }}?from_project={{ project_id }}"
class="text-seismo-orange hover:underline font-medium">{{ unit.id }}</a>
</div>
{% endif %}
{% if s.device_model %}
<div class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
</svg>
<span>{{ s.device_model }}</span>
</div>
{% endif %}
</div>
{% if item.session.notes %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
{{ item.session.notes }}
</p>
{% if s.notes %}
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 italic">{{ s.notes }}</p>
{% endif %}
</div>
<div class="flex items-center gap-2">
{% if item.session.status == 'recording' %}
<button onclick="stopRecording('{{ item.session.id }}')"
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
Stop
</button>
<div class="flex items-center gap-2 shrink-0">
{% if s.status == 'recording' %}
<button onclick="stopRecording('{{ s.id }}')"
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
Stop
</button>
{% endif %}
<button onclick="viewSession('{{ item.session.id }}')"
<button onclick="viewSession('{{ s.id }}')"
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
Details
</button>
@@ -84,24 +154,107 @@
</div>
{% else %}
<div class="text-center py-12">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400 mb-2">No monitoring sessions yet</p>
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
<p class="text-gray-500 dark:text-gray-400 mb-1">No monitoring sessions yet</p>
<p class="text-sm text-gray-400 dark:text-gray-500">Upload data to create sessions</p>
</div>
{% endif %}
<script>
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',
weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
};
const PERIOD_LABELS = {
weekday_day: 'Weekday Day',
weekday_night: 'Weekday Night',
weekend_day: 'Weekend Day',
weekend_night: 'Weekend Night',
};
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');
});
menu.classList.toggle('hidden');
}
document.addEventListener('click', function(e) {
if (!e.target.closest('[id^="period-wrap-"]')) {
document.querySelectorAll('[id^="period-menu-"]').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;
}
}
function startEditLabel(sessionId) {
document.getElementById('label-display-' + sessionId).classList.add('hidden');
const input = document.getElementById('label-input-' + sessionId);
input.classList.remove('hidden');
input.focus();
input.select();
}
function cancelEditLabel(sessionId) {
document.getElementById('label-input-' + sessionId).classList.add('hidden');
document.getElementById('label-display-' + sessionId).classList.remove('hidden');
}
async function saveLabel(sessionId) {
const display = document.getElementById('label-display-' + sessionId);
const input = document.getElementById('label-input-' + sessionId);
const newLabel = input.value.trim();
try {
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({session_label: newLabel}),
});
if (!resp.ok) throw new Error(await resp.text());
display.textContent = newLabel || ('Session ' + sessionId.slice(0, 8) + '…');
} catch(err) {
alert('Failed to save label: ' + err.message);
} finally {
input.classList.add('hidden');
display.classList.remove('hidden');
}
}
function viewSession(sessionId) {
// TODO: Implement session detail modal or page
alert('Session details coming soon: ' + sessionId);
}
function stopRecording(sessionId) {
if (!confirm('Stop this monitoring session?')) return;
// TODO: Implement stop recording API call
alert('Stop recording API coming soon for session: ' + sessionId);
}
</script>

View File

@@ -31,6 +31,9 @@
<div>
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
{% if out_for_calibration > 0 %}
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1">{{ out_for_calibration }} out for calibration</p>
{% endif %}
</div>
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>

View File

@@ -106,6 +106,13 @@
</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">

View File

@@ -40,12 +40,12 @@
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>
<button onclick="switchTab('units')"
<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 onclick="switchTab('schedules')"
<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
@@ -235,7 +235,7 @@
<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 Days
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">
@@ -248,7 +248,7 @@
</div>
</div>
<!-- Upload Days Panel -->
<!-- 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>
@@ -264,16 +264,28 @@
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" />
<button onclick="submitUploadAll()"
<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 onclick="toggleUploadAll()"
<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>
<!-- 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>
</div>
@@ -324,6 +336,30 @@
</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"
@@ -775,6 +811,12 @@ async function loadProjectDetails() {
document.getElementById('settings-start-date').value = formatDate(data.start_date);
document.getElementById('settings-end-date').value = formatDate(data.end_date);
// Update data collection mode radio
const mode = data.data_collection_mode || 'manual';
const modeRadio = document.getElementById('settings-mode-' + mode);
if (modeRadio) modeRadio.checked = true;
settingsUpdateModeStyles();
// Update tab labels and visibility based on project type
const isSoundProject = projectTypeId === 'sound_monitoring';
if (isSoundProject) {
@@ -782,9 +824,16 @@ async function loadProjectDetails() {
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
document.getElementById('add-location-label').textContent = 'Add NRL';
}
// Monitoring Sessions and Data Files tabs are SLM-only
// Monitoring Sessions and Data Files tabs are sound-only
// Data Files also hides the FTP browser section for manual projects
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('ftp-browser')?.classList.toggle('hidden', !isRemote);
document.getElementById('settings-error').classList.add('hidden');
updateDangerZone();
@@ -800,6 +849,24 @@ function formatDate(value) {
return date.toISOString().slice(0, 10);
}
function settingsUpdateModeStyles() {
const manualChecked = document.getElementById('settings-mode-manual')?.checked;
const manualLabel = document.getElementById('settings-mode-manual-label');
const remoteLabel = document.getElementById('settings-mode-remote-label');
if (!manualLabel || !remoteLabel) return;
if (manualChecked) {
manualLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
manualLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
remoteLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
remoteLabel.classList.add('border-gray-300', 'dark:border-gray-600');
} else {
remoteLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
remoteLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
manualLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
manualLabel.classList.add('border-gray-300', 'dark:border-gray-600');
}
}
// Project settings form submission
document.getElementById('project-settings-form').addEventListener('submit', async function(e) {
e.preventDefault();
@@ -812,7 +879,8 @@ document.getElementById('project-settings-form').addEventListener('submit', asyn
site_address: document.getElementById('settings-site-address').value.trim() || null,
site_coordinates: document.getElementById('settings-site-coordinates').value.trim() || null,
start_date: document.getElementById('settings-start-date').value || null,
end_date: document.getElementById('settings-end-date').value || null
end_date: document.getElementById('settings-end-date').value || null,
data_collection_mode: document.querySelector('input[name="data_collection_mode"]:checked')?.value || 'manual'
};
try {
@@ -1575,7 +1643,7 @@ document.getElementById('schedule-modal')?.addEventListener('click', function(e)
}
});
// ── Upload Days ───────────────────────────────────────────────────────────────
// ── Upload Data ───────────────────────────────────────────────────────────────
function toggleUploadAll() {
const panel = document.getElementById('upload-all-panel');
@@ -1586,75 +1654,148 @@ function toggleUploadAll() {
document.getElementById('upload-all-results').classList.add('hidden');
document.getElementById('upload-all-results').innerHTML = '';
document.getElementById('upload-all-input').value = '';
document.getElementById('upload-all-file-count').classList.add('hidden');
document.getElementById('upload-all-progress-wrap').classList.add('hidden');
document.getElementById('upload-all-progress-bar').style.width = '0%';
}
}
async function submitUploadAll() {
// Show file count and filter info when folder is selected
document.getElementById('upload-all-input').addEventListener('change', function() {
const countEl = document.getElementById('upload-all-file-count');
const total = this.files.length;
if (!total) { countEl.classList.add('hidden'); return; }
const wanted = Array.from(this.files).filter(_isWantedFile).length;
countEl.textContent = `${wanted} of ${total} files will be uploaded (Leq + .rnh only)`;
countEl.classList.remove('hidden');
});
function _isWantedFile(f) {
const n = (f.webkitRelativePath || f.name).toLowerCase();
const base = n.split('/').pop();
if (base.endsWith('.rnh')) return true;
if (base.endsWith('.rnd')) {
if (base.includes('_leq_')) return true; // NL-43 Leq
if (base.startsWith('au2_')) return true; // AU2/NL-23 format
if (!base.includes('_lp')) return true; // unknown format — keep
}
return false;
}
function submitUploadAll() {
const input = document.getElementById('upload-all-input');
const status = document.getElementById('upload-all-status');
const resultsEl = document.getElementById('upload-all-results');
const btn = document.getElementById('upload-all-btn');
const cancelBtn = document.getElementById('upload-all-cancel-btn');
const progressWrap = document.getElementById('upload-all-progress-wrap');
const progressBar = document.getElementById('upload-all-progress-bar');
const progressLabel = document.getElementById('upload-all-progress-label');
if (!input.files.length) {
alert('Please select a folder to upload.');
return;
}
// Filter client-side — only send Leq .rnd and .rnh files
const filesToSend = Array.from(input.files).filter(_isWantedFile);
if (!filesToSend.length) {
alert('No Leq .rnd or .rnh files found in selected folder.');
return;
}
const formData = new FormData();
for (const f of input.files) {
// webkitRelativePath gives the path relative to the selected folder root
for (const f of filesToSend) {
formData.append('files', f);
formData.append('paths', f.webkitRelativePath || f.name);
}
status.textContent = `Uploading ${input.files.length} files\u2026`;
status.className = 'text-sm text-gray-500';
// Disable controls and show progress
btn.disabled = true;
btn.textContent = 'Uploading\u2026';
btn.classList.add('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = true;
cancelBtn.classList.add('opacity-40', 'cursor-not-allowed');
status.className = 'text-sm hidden';
resultsEl.classList.add('hidden');
progressWrap.classList.remove('hidden');
progressBar.style.width = '0%';
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026`;
try {
const response = await fetch(
`/api/projects/{{ project_id }}/upload-all`,
{ method: 'POST', body: formData }
);
const data = await response.json();
const xhr = new XMLHttpRequest();
if (response.ok) {
const s = data.sessions_created;
const f = data.files_imported;
status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`;
status.className = 'text-sm text-green-600 dark:text-green-400';
input.value = '';
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = pct + '%';
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026 ${pct}%`;
}
});
// Build results summary
let html = '';
if (data.sessions && data.sessions.length) {
html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
html += '<ul class="space-y-0.5 ml-2">';
for (const sess of data.sessions) {
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> &mdash; ${sess.files} files`;
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
if (sess.store_name) html += ` &mdash; ${sess.store_name}`;
html += '</li>';
xhr.upload.addEventListener('load', () => {
progressBar.style.width = '100%';
progressLabel.textContent = 'Processing files on server\u2026';
});
function _resetControls() {
progressWrap.classList.add('hidden');
btn.disabled = false;
btn.textContent = 'Import';
btn.classList.remove('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = false;
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
}
xhr.addEventListener('load', () => {
_resetControls();
try {
const data = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300) {
const s = data.sessions_created;
const f = data.files_imported;
status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`;
status.className = 'text-sm text-green-600 dark:text-green-400';
input.value = '';
document.getElementById('upload-all-file-count').classList.add('hidden');
let html = '';
if (data.sessions && data.sessions.length) {
html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
html += '<ul class="space-y-0.5 ml-2">';
for (const sess of data.sessions) {
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> &mdash; ${sess.files} files`;
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
if (sess.store_name) html += ` &mdash; ${sess.store_name}`;
html += '</li>';
}
html += '</ul>';
}
html += '</ul>';
if (data.unmatched_folders && data.unmatched_folders.length) {
html += `<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}</div>`;
}
if (html) {
resultsEl.innerHTML = html;
resultsEl.classList.remove('hidden');
}
htmx.trigger(document.getElementById('unified-files'), 'refresh');
} else {
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
status.className = 'text-sm text-red-600 dark:text-red-400';
}
if (data.unmatched_folders && data.unmatched_folders.length) {
html += `<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}</div>`;
}
if (html) {
resultsEl.innerHTML = html;
resultsEl.classList.remove('hidden');
}
// Refresh the unified files view
htmx.trigger(document.getElementById('unified-files'), 'refresh');
} else {
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
} catch {
status.textContent = 'Error: Unexpected server response';
status.className = 'text-sm text-red-600 dark:text-red-400';
}
} catch (err) {
status.textContent = `Error: ${err.message}`;
});
xhr.addEventListener('error', () => {
_resetControls();
status.textContent = 'Error: Network error during upload';
status.className = 'text-sm text-red-600 dark:text-red-400';
}
});
xhr.open('POST', `/api/projects/{{ project_id }}/upload-all`);
xhr.send(formData);
}
// Load project details on page load and restore active tab from URL hash

View File

@@ -60,7 +60,7 @@
</div>
<div class="flex items-center gap-3">
{# Only show Report button for Leq files (15-min averaged data with LN percentiles) #}
{% if file and '_Leq_' in file.file_path %}
{% if is_leq %}
<!-- Generate Excel Report Button -->
<button onclick="openReportModal()"
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2">

View File

@@ -56,6 +56,7 @@
<option value="">All Status</option>
<option value="deployed">Deployed</option>
<option value="benched">Benched</option>
<option value="out_for_calibration">Out for Calibration</option>
</select>
<!-- Modem Filter -->

View File

@@ -41,6 +41,12 @@
</svg>
Danger Zone
</button>
<button class="settings-tab text-gray-400 dark:text-gray-500" data-tab="developer" onclick="showTab('developer')">
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
Developer
</button>
</div>
<!-- General Tab -->
@@ -514,6 +520,32 @@
</div>
</div>
<!-- Developer Tab -->
<div id="developer-tab" class="tab-content hidden">
<div class="space-y-6">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-1">Developer Tools</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Admin-only tools for managing field watcher agents and diagnosing connectivity.</p>
<div class="space-y-4">
<!-- Watcher Manager -->
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
<div>
<div class="font-medium text-gray-900 dark:text-white">Watcher Manager</div>
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Monitor series3-watcher and thor-watcher agents. View status, log tails, and push remote updates.
</div>
</div>
<a href="/admin/watchers"
class="ml-6 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
Open
</a>
</div>
</div>
</div>
</div>
</div>
<style>
.settings-tab {
padding: 0.75rem 1.5rem;

View File

@@ -92,7 +92,7 @@
<p id="deployedStatus" class="font-medium text-gray-900 dark:text-white">--</p>
</div>
<div>
<span class="text-sm text-gray-500 dark:text-gray-400">Retired</span>
<span class="text-sm text-gray-500 dark:text-gray-400">Unit Status</span>
<p id="retiredStatus" class="font-medium text-gray-900 dark:text-white">--</p>
</div>
</div>
@@ -497,18 +497,28 @@
</div>
</div>
<!-- Checkboxes -->
<div class="flex items-center gap-6 border-t border-gray-200 dark:border-gray-700 pt-4">
<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">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="retired" id="retired" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Retired</span>
</label>
<!-- 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">
<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">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="out_for_calibration" id="outForCalibration" value="true"
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>
</div>
<!-- Hidden field for retired — controlled by the Retire button below -->
<input type="hidden" name="retired" id="retired" value="">
<div id="retireButtonSection">
<button type="button" id="retireBtn"
class="px-4 py-2 text-sm font-medium rounded-lg border transition-colors"
onclick="toggleRetired()">
</button>
</div>
</div>
<!-- Notes -->
@@ -817,7 +827,16 @@ function populateViewMode() {
}
document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
document.getElementById('retiredStatus').textContent = currentUnit.retired ? 'Yes' : 'No';
if (currentUnit.retired) {
document.getElementById('retiredStatus').textContent = 'Retired';
document.getElementById('retiredStatus').className = 'font-medium text-red-600 dark:text-red-400';
} 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 {
document.getElementById('retiredStatus').textContent = 'Active';
document.getElementById('retiredStatus').className = 'font-medium text-gray-900 dark:text-white';
}
// Basic info
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
@@ -1009,7 +1028,9 @@ function populateEditForm() {
document.getElementById('address').value = currentUnit.address || '';
document.getElementById('coordinates').value = currentUnit.coordinates || '';
document.getElementById('deployed').checked = currentUnit.deployed;
document.getElementById('retired').checked = currentUnit.retired;
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
updateRetireButton(currentUnit.retired);
document.getElementById('note').value = currentUnit.note || '';
// Seismograph fields
@@ -1153,6 +1174,25 @@ function cancelEdit() {
populateEditForm();
}
function updateRetireButton(isRetired) {
const btn = document.getElementById('retireBtn');
if (!btn) return;
if (isRetired) {
btn.textContent = 'Un-Retire Unit';
btn.className = 'px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600';
} else {
btn.textContent = 'Retire Unit';
btn.className = 'px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/40';
}
}
function toggleRetired() {
const hiddenInput = document.getElementById('retired');
const isCurrentlyRetired = hiddenInput.value === 'true';
hiddenInput.value = isCurrentlyRetired ? '' : 'true';
updateRetireButton(!isCurrentlyRetired);
}
// Handle form submission
document.getElementById('editForm').addEventListener('submit', async function(e) {
e.preventDefault();