Compare commits
27 Commits
14856e61ef
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ea64c3561 | |||
| 1a87ff13c9 | |||
| 22c62c0729 | |||
| 0f47b69c92 | |||
| 76667454b3 | |||
|
|
15d962ba42 | ||
|
|
b571dc29bc | ||
|
|
e2c841d5d7 | ||
| cc94493331 | |||
|
|
5a5426cceb | ||
|
|
66eddd6fe2 | ||
|
|
c77794787c | ||
| 61c84bc71d | |||
| fbf7f2a65d | |||
|
|
202fcaf91c | ||
|
|
3a411d0a89 | ||
| 0c2186f5d8 | |||
| c138e8c6a0 | |||
| 1dd396acd8 | |||
| e89a04f58c | |||
| e4ef065db8 | |||
| 86010de60c | |||
| f89f04cd6f | |||
| 67a2faa2d3 | |||
| b15d434fce | |||
| 7b4e12c127 | |||
| 742a98a8ed |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# Terra-View Specifics
|
||||
# Dev build counter (local only, never commit)
|
||||
build_number.txt
|
||||
docker-compose.override.yml
|
||||
|
||||
# SQLite database files
|
||||
*.db
|
||||
@@ -9,7 +10,7 @@ data/
|
||||
data-dev/
|
||||
.aider*
|
||||
.aider*
|
||||
docker-compose.override.yml
|
||||
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -219,3 +220,14 @@ marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
<<<<<<< HEAD
|
||||
# Seismo Fleet Manager
|
||||
# SQLite database files
|
||||
*.db
|
||||
*.db-journal
|
||||
/data/
|
||||
/data-dev/
|
||||
.aider*
|
||||
.aider*
|
||||
=======
|
||||
>>>>>>> 0c2186f5d89d948b0357d674c0773a67a67d8027
|
||||
|
||||
93
CHANGELOG.md
93
CHANGELOG.md
@@ -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 (7AM–6:59PM); night sessions span both days (7PM–6: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
|
||||
|
||||
29
README.md
29
README.md
@@ -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)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ 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":
|
||||
@@ -102,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)
|
||||
@@ -659,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"),
|
||||
@@ -683,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"),
|
||||
@@ -707,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"),
|
||||
@@ -731,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"),
|
||||
@@ -748,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)
|
||||
|
||||
|
||||
54
backend/migrate_add_out_for_calibration.py
Normal file
54
backend/migrate_add_out_for_calibration.py
Normal 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()
|
||||
@@ -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]
|
||||
|
||||
131
backend/migrate_add_session_period_type.py
Normal file
131
backend/migrate_add_session_period_type.py
Normal 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()
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
@@ -272,6 +293,14 @@ class MonitoringSession(Base):
|
||||
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
|
||||
|
||||
|
||||
@@ -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:00–07:00, Day = 07:00–22: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,
|
||||
|
||||
@@ -1794,6 +1794,34 @@ async def delete_session(
|
||||
})
|
||||
|
||||
|
||||
VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"}
|
||||
|
||||
@router.patch("/{project_id}/sessions/{session_id}")
|
||||
async def patch_session(
|
||||
project_id: str,
|
||||
session_id: str,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update session_label and/or period_type on a monitoring session."""
|
||||
session = db.query(MonitoringSession).filter_by(id=session_id).first()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.project_id != project_id:
|
||||
raise HTTPException(status_code=403, detail="Session does not belong to this project")
|
||||
|
||||
if "session_label" in data:
|
||||
session.session_label = str(data["session_label"]).strip() or None
|
||||
if "period_type" in data:
|
||||
pt = data["period_type"]
|
||||
if pt and pt not in VALID_PERIOD_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid period_type. Must be one of: {', '.join(sorted(VALID_PERIOD_TYPES))}")
|
||||
session.period_type = pt or None
|
||||
|
||||
db.commit()
|
||||
return JSONResponse({"status": "success", "session_label": session.session_label, "period_type": session.period_type})
|
||||
|
||||
|
||||
@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse)
|
||||
async def view_rnd_file(
|
||||
request: Request,
|
||||
@@ -2010,6 +2038,7 @@ async def generate_excel_report(
|
||||
import openpyxl
|
||||
from openpyxl.chart import LineChart, Reference
|
||||
from openpyxl.chart.label import DataLabelList
|
||||
from openpyxl.chart.shapes import GraphicalProperties
|
||||
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
except ImportError:
|
||||
@@ -2312,6 +2341,10 @@ async def generate_excel_report(
|
||||
chart.series[2].graphicalProperties.line.solidFill = "0070C0"
|
||||
chart.series[2].graphicalProperties.line.width = 19050
|
||||
|
||||
_plot_border = GraphicalProperties()
|
||||
_plot_border.ln.solidFill = "000000"
|
||||
_plot_border.ln.w = 12700
|
||||
chart.plot_area.spPr = _plot_border
|
||||
ws.add_chart(chart, "H4")
|
||||
|
||||
# --- Stats table: note at I28-I29, headers at I31, data rows 32-34 ---
|
||||
@@ -2651,6 +2684,7 @@ async def generate_report_from_preview(
|
||||
try:
|
||||
import openpyxl
|
||||
from openpyxl.chart import LineChart, Reference
|
||||
from openpyxl.chart.shapes import GraphicalProperties
|
||||
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
except ImportError:
|
||||
@@ -2793,6 +2827,10 @@ async def generate_report_from_preview(
|
||||
chart.series[1].graphicalProperties.line.width = 19050
|
||||
chart.series[2].graphicalProperties.line.solidFill = "0070C0"
|
||||
chart.series[2].graphicalProperties.line.width = 19050
|
||||
_plot_border = GraphicalProperties()
|
||||
_plot_border.ln.solidFill = "000000"
|
||||
_plot_border.ln.w = 12700
|
||||
chart.plot_area.spPr = _plot_border
|
||||
ws.add_chart(chart, "H4")
|
||||
|
||||
# --- Stats block starting at I28 ---
|
||||
@@ -2929,6 +2967,7 @@ async def generate_combined_excel_report(
|
||||
try:
|
||||
import openpyxl
|
||||
from openpyxl.chart import LineChart, Reference
|
||||
from openpyxl.chart.shapes import GraphicalProperties
|
||||
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
except ImportError:
|
||||
@@ -3135,6 +3174,10 @@ async def generate_combined_excel_report(
|
||||
chart.series[2].graphicalProperties.line.solidFill = "0070C0"
|
||||
chart.series[2].graphicalProperties.line.width = 19050
|
||||
|
||||
_plot_border = GraphicalProperties()
|
||||
_plot_border.ln.solidFill = "000000"
|
||||
_plot_border.ln.w = 12700
|
||||
chart.plot_area.spPr = _plot_border
|
||||
ws.add_chart(chart, "H4")
|
||||
|
||||
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
|
||||
@@ -3277,32 +3320,59 @@ async def combined_report_wizard(
|
||||
):
|
||||
"""Configuration page for the combined multi-location report wizard."""
|
||||
from backend.models import ReportTemplate
|
||||
from pathlib import Path as _Path
|
||||
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
|
||||
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).order_by(MonitoringSession.started_at).all()
|
||||
|
||||
# Build location list with Leq file counts (no filtering)
|
||||
location_file_counts: dict = {}
|
||||
# Build location -> sessions list, only including sessions that have Leq files
|
||||
location_sessions: dict = {} # loc_name -> list of session dicts
|
||||
for session in sessions:
|
||||
files = db.query(DataFile).filter_by(session_id=session.id).all()
|
||||
has_leq = False
|
||||
for file in files:
|
||||
if not file.file_path or not file.file_path.lower().endswith('.rnd'):
|
||||
continue
|
||||
from pathlib import Path as _Path
|
||||
abs_path = _Path("data") / file.file_path
|
||||
peek = _peek_rnd_headers(abs_path)
|
||||
if not _is_leq_file(file.file_path, peek):
|
||||
if _is_leq_file(file.file_path, peek):
|
||||
has_leq = True
|
||||
break
|
||||
if not has_leq:
|
||||
continue
|
||||
|
||||
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||
loc_name = location.name if location else f"Session {session.id[:8]}"
|
||||
location_file_counts[loc_name] = location_file_counts.get(loc_name, 0) + 1
|
||||
|
||||
if loc_name not in location_sessions:
|
||||
location_sessions[loc_name] = []
|
||||
|
||||
# Build a display date and day-of-week from started_at
|
||||
date_display = ""
|
||||
day_of_week = ""
|
||||
if session.started_at:
|
||||
date_display = session.started_at.strftime("%-m/%-d/%Y")
|
||||
day_of_week = session.started_at.strftime("%A") # Monday, Sunday, etc.
|
||||
|
||||
location_sessions[loc_name].append({
|
||||
"session_id": session.id,
|
||||
"session_label": session.session_label or "",
|
||||
"date_display": date_display,
|
||||
"day_of_week": day_of_week,
|
||||
"started_at": session.started_at.isoformat() if session.started_at else "",
|
||||
"stopped_at": session.stopped_at.isoformat() if session.stopped_at else "",
|
||||
"duration_h": (session.duration_seconds // 3600) if session.duration_seconds else 0,
|
||||
"duration_m": ((session.duration_seconds % 3600) // 60) if session.duration_seconds else 0,
|
||||
"period_type": session.period_type or "",
|
||||
"status": session.status,
|
||||
})
|
||||
|
||||
locations = [
|
||||
{"name": name, "file_count": count}
|
||||
for name, count in sorted(location_file_counts.items())
|
||||
{"name": name, "sessions": sess_list}
|
||||
for name, sess_list in sorted(location_sessions.items())
|
||||
]
|
||||
|
||||
report_templates = db.query(ReportTemplate).all()
|
||||
@@ -3312,10 +3382,160 @@ async def combined_report_wizard(
|
||||
"project": project,
|
||||
"project_id": project_id,
|
||||
"locations": locations,
|
||||
"locations_json": json.dumps(locations),
|
||||
"report_templates": report_templates,
|
||||
})
|
||||
|
||||
|
||||
def _build_location_data_from_sessions(project_id: str, db, selected_session_ids: list) -> dict:
|
||||
"""
|
||||
Build per-location spreadsheet data using an explicit list of session IDs.
|
||||
Only rows from those sessions are included. Per-session period_type is
|
||||
stored on each row so the report can filter stats correctly.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
if not selected_session_ids:
|
||||
raise HTTPException(status_code=400, detail="No sessions selected.")
|
||||
|
||||
# Load every requested session — one entry per (session_id, loc_name) pair.
|
||||
# Keyed by session_id so overnight sessions are never split by calendar date.
|
||||
session_entries: dict = {} # session_id -> {loc_name, session_label, period_type, rows[]}
|
||||
|
||||
for session_id in selected_session_ids:
|
||||
session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first()
|
||||
if not session:
|
||||
continue
|
||||
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
|
||||
loc_name = location.name if location else f"Session {session_id[:8]}"
|
||||
|
||||
session_entries[session_id] = {
|
||||
"loc_name": loc_name,
|
||||
"session_label": session.session_label or "",
|
||||
"period_type": session.period_type or "",
|
||||
"started_at": session.started_at,
|
||||
"rows": [],
|
||||
}
|
||||
|
||||
files = db.query(DataFile).filter_by(session_id=session_id).all()
|
||||
for file in files:
|
||||
if not file.file_path or not file.file_path.lower().endswith('.rnd'):
|
||||
continue
|
||||
abs_path = _Path("data") / file.file_path
|
||||
peek = _peek_rnd_headers(abs_path)
|
||||
if not _is_leq_file(file.file_path, peek):
|
||||
continue
|
||||
rows = _read_rnd_file_rows(file.file_path)
|
||||
rows, _ = _normalize_rnd_rows(rows)
|
||||
session_entries[session_id]["rows"].extend(rows)
|
||||
|
||||
if not any(e["rows"] for e in session_entries.values()):
|
||||
raise HTTPException(status_code=404, detail="No Leq data found in the selected sessions.")
|
||||
|
||||
location_data = []
|
||||
for session_id in selected_session_ids:
|
||||
entry = session_entries.get(session_id)
|
||||
if not entry or not entry["rows"]:
|
||||
continue
|
||||
|
||||
loc_name = entry["loc_name"]
|
||||
period_type = entry["period_type"]
|
||||
raw_rows = sorted(entry["rows"], key=lambda r: r.get('Start Time', ''))
|
||||
|
||||
# Parse all rows to datetimes first so we can apply period-aware filtering
|
||||
parsed = []
|
||||
for row in raw_rows:
|
||||
start_time_str = row.get('Start Time', '')
|
||||
dt = None
|
||||
if start_time_str:
|
||||
try:
|
||||
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
|
||||
except ValueError:
|
||||
pass
|
||||
parsed.append((dt, row))
|
||||
|
||||
# Determine which rows to keep based on period_type
|
||||
is_day_session = period_type in ('weekday_day', 'weekend_day')
|
||||
target_date = None
|
||||
if is_day_session:
|
||||
# Day: 07:00–18:59 only, restricted to the LAST calendar date that has daytime rows
|
||||
daytime_dates = sorted({
|
||||
dt.date() for dt, row in parsed
|
||||
if dt and 7 <= dt.hour < 19
|
||||
})
|
||||
target_date = daytime_dates[-1] if daytime_dates else None
|
||||
filtered = [
|
||||
(dt, row) for dt, row in parsed
|
||||
if dt and dt.date() == target_date and 7 <= dt.hour < 19
|
||||
]
|
||||
else:
|
||||
# Night: 19:00–06:59, spanning both calendar days — no date restriction
|
||||
filtered = [
|
||||
(dt, row) for dt, row in parsed
|
||||
if dt and (dt.hour >= 19 or dt.hour < 7)
|
||||
]
|
||||
|
||||
# Fall back to all rows if filtering removed everything
|
||||
if not filtered:
|
||||
filtered = parsed
|
||||
|
||||
spreadsheet_data = []
|
||||
for idx, (dt, row) in enumerate(filtered, 1):
|
||||
date_str = dt.strftime('%Y-%m-%d') if dt else ''
|
||||
time_str = dt.strftime('%H:%M') if dt else ''
|
||||
|
||||
lmax = row.get('Lmax(Main)', '')
|
||||
ln1 = row.get('LN1(Main)', '')
|
||||
ln2 = row.get('LN2(Main)', '')
|
||||
|
||||
spreadsheet_data.append([
|
||||
idx,
|
||||
date_str,
|
||||
time_str,
|
||||
lmax if lmax else '',
|
||||
ln1 if ln1 else '',
|
||||
ln2 if ln2 else '',
|
||||
'',
|
||||
period_type, # col index 7 — hidden, used by report gen for day/night bucketing
|
||||
])
|
||||
|
||||
# For the label/filename, use target_date (day sessions) or started_at (night sessions)
|
||||
from datetime import timedelta as _td
|
||||
started_at_dt = entry["started_at"]
|
||||
if is_day_session and target_date:
|
||||
# Use the actual target date from data filtering (last date with daytime rows)
|
||||
label_dt = datetime.combine(target_date, datetime.min.time())
|
||||
else:
|
||||
label_dt = started_at_dt
|
||||
|
||||
# Rebuild session label using the correct label date
|
||||
if label_dt and entry["loc_name"]:
|
||||
period_str = {"weekday_day": "Day", "weekday_night": "Night",
|
||||
"weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "")
|
||||
day_abbr = label_dt.strftime("%a")
|
||||
date_label = f"{label_dt.month}/{label_dt.day}"
|
||||
session_label = " — ".join(p for p in [loc_name, f"{day_abbr} {date_label}", period_str] if p)
|
||||
else:
|
||||
session_label = entry["session_label"]
|
||||
|
||||
location_data.append({
|
||||
"session_id": session_id,
|
||||
"location_name": loc_name,
|
||||
"session_label": session_label,
|
||||
"period_type": period_type,
|
||||
"started_at": label_dt.isoformat() if label_dt else "",
|
||||
"raw_count": len(raw_rows),
|
||||
"filtered_count": len(filtered),
|
||||
"spreadsheet_data": spreadsheet_data,
|
||||
})
|
||||
|
||||
return {"project": project, "location_data": location_data}
|
||||
|
||||
|
||||
@router.get("/{project_id}/combined-report-preview", response_class=HTMLResponse)
|
||||
async def combined_report_preview(
|
||||
request: Request,
|
||||
@@ -3323,38 +3543,19 @@ async def combined_report_preview(
|
||||
report_title: str = Query("Background Noise Study"),
|
||||
project_name: str = Query(""),
|
||||
client_name: str = Query(""),
|
||||
start_time: str = Query(""),
|
||||
end_time: str = Query(""),
|
||||
start_date: str = Query(""),
|
||||
end_date: str = Query(""),
|
||||
enabled_locations: str = Query(""),
|
||||
selected_sessions: str = Query(""), # comma-separated session IDs
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Preview and edit combined report data before generating the Excel file."""
|
||||
enabled_list = [loc.strip() for loc in enabled_locations.split(',') if loc.strip()] if enabled_locations else None
|
||||
session_ids = [s.strip() for s in selected_sessions.split(',') if s.strip()] if selected_sessions else []
|
||||
|
||||
result = _build_combined_location_data(
|
||||
project_id, db,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
enabled_locations=enabled_list,
|
||||
)
|
||||
result = _build_location_data_from_sessions(project_id, db, session_ids)
|
||||
|
||||
project = result["project"]
|
||||
location_data = result["location_data"]
|
||||
|
||||
total_rows = sum(loc["filtered_count"] for loc in location_data)
|
||||
final_project_name = project_name if project_name else project.name
|
||||
|
||||
# Build time filter display string
|
||||
time_filter_desc = ""
|
||||
if start_time and end_time:
|
||||
time_filter_desc = f"{start_time} – {end_time}"
|
||||
elif start_time or end_time:
|
||||
time_filter_desc = f"{start_time or ''} – {end_time or ''}"
|
||||
|
||||
return templates.TemplateResponse("combined_report_preview.html", {
|
||||
"request": request,
|
||||
"project": project,
|
||||
@@ -3362,11 +3563,7 @@ async def combined_report_preview(
|
||||
"report_title": report_title,
|
||||
"project_name": final_project_name,
|
||||
"client_name": client_name,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"time_filter_desc": time_filter_desc,
|
||||
"time_filter_desc": f"{len(session_ids)} session{'s' if len(session_ids) != 1 else ''} selected",
|
||||
"location_data": location_data,
|
||||
"locations_json": json.dumps(location_data),
|
||||
"total_rows": total_rows,
|
||||
@@ -3379,12 +3576,18 @@ async def generate_combined_from_preview(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Generate combined Excel report from wizard-edited spreadsheet data."""
|
||||
"""Generate combined Excel report from wizard-edited spreadsheet data.
|
||||
|
||||
Produces one .xlsx per day (each with one sheet per location) packaged
|
||||
into a single .zip file for download.
|
||||
"""
|
||||
try:
|
||||
import openpyxl
|
||||
from openpyxl.chart import LineChart, Reference
|
||||
from openpyxl.chart.shapes import GraphicalProperties
|
||||
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.worksheet.properties import PageSetupProperties
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=500, detail="openpyxl is not installed. Run: pip install openpyxl")
|
||||
|
||||
@@ -3400,12 +3603,13 @@ async def generate_combined_from_preview(
|
||||
if not locations:
|
||||
raise HTTPException(status_code=400, detail="No location data provided")
|
||||
|
||||
# Styles
|
||||
# Shared styles
|
||||
f_title = Font(name='Arial', bold=True, size=12)
|
||||
f_bold = Font(name='Arial', bold=True, size=10)
|
||||
f_data = Font(name='Arial', size=10)
|
||||
thin = Side(style='thin')
|
||||
dbl = Side(style='double')
|
||||
med = Side(style='medium')
|
||||
hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin)
|
||||
hdr_left = Border(left=dbl, right=thin, top=dbl, bottom=thin)
|
||||
hdr_right = Border(left=thin, right=dbl, top=dbl, bottom=thin)
|
||||
@@ -3418,31 +3622,25 @@ async def generate_combined_from_preview(
|
||||
hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
|
||||
center_a = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||
left_a = Alignment(horizontal='left', vertical='center')
|
||||
right_a = Alignment(horizontal='right', vertical='center')
|
||||
thin_border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
|
||||
from openpyxl.worksheet.properties import PageSetupProperties
|
||||
tbl_top_left = Border(left=med, right=thin, top=med, bottom=thin)
|
||||
tbl_top_mid = Border(left=thin, right=thin, top=med, bottom=thin)
|
||||
tbl_top_right = Border(left=thin, right=med, top=med, bottom=thin)
|
||||
tbl_mid_left = Border(left=med, right=thin, top=thin, bottom=thin)
|
||||
tbl_mid_mid = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
tbl_mid_right = Border(left=thin, right=med, top=thin, bottom=thin)
|
||||
tbl_bot_left = Border(left=med, right=thin, top=thin, bottom=med)
|
||||
tbl_bot_mid = Border(left=thin, right=thin, top=thin, bottom=med)
|
||||
tbl_bot_right = Border(left=thin, right=med, top=thin, bottom=med)
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
wb.remove(wb.active)
|
||||
col_widths = [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 18.0, 18.0, 14.0, 14.0, 10.0, 8.0, 6.43, 6.43]
|
||||
|
||||
all_location_summaries = []
|
||||
|
||||
for loc_info in locations:
|
||||
loc_name = loc_info.get("location_name", "Unknown")
|
||||
rows = loc_info.get("spreadsheet_data", [])
|
||||
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
safe_sheet_name = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31]
|
||||
ws = wb.create_sheet(title=safe_sheet_name)
|
||||
|
||||
# Column widths from Soundstudyexample.xlsx NRL_1 (sheet2)
|
||||
# A B C D E F G H I J K L M N O P
|
||||
for col_i, col_w in zip(range(1, 17), [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]):
|
||||
def _build_location_sheet(ws, loc_name, day_rows, final_title):
|
||||
"""Write one location's data onto ws. day_rows is a list of spreadsheet row arrays."""
|
||||
for col_i, col_w in zip(range(1, 17), col_widths):
|
||||
ws.column_dimensions[get_column_letter(col_i)].width = col_w
|
||||
|
||||
final_title = f"{report_title} - {project_name}"
|
||||
ws.merge_cells('A1:G1')
|
||||
ws['A1'] = final_title
|
||||
ws['A1'].font = f_title; ws['A1'].alignment = center_a
|
||||
@@ -3453,6 +3651,28 @@ async def generate_combined_from_preview(
|
||||
ws['A3'] = loc_name
|
||||
ws['A3'].font = f_title; ws['A3'].alignment = center_a
|
||||
ws.row_dimensions[3].height = 15.75
|
||||
|
||||
# Row 4: date range derived from the data rows
|
||||
def _fmt_date(d):
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
return _dt.strptime(d, '%Y-%m-%d').strftime('%-m/%-d/%y')
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
dates_in_data = sorted({
|
||||
row[1] for row in day_rows
|
||||
if len(row) > 1 and row[1]
|
||||
})
|
||||
if len(dates_in_data) >= 2:
|
||||
date_label = f"{_fmt_date(dates_in_data[0])} to {_fmt_date(dates_in_data[-1])}"
|
||||
elif len(dates_in_data) == 1:
|
||||
date_label = _fmt_date(dates_in_data[0])
|
||||
else:
|
||||
date_label = ""
|
||||
ws.merge_cells('A4:G4')
|
||||
ws['A4'] = date_label
|
||||
ws['A4'].font = f_data; ws['A4'].alignment = center_a
|
||||
ws.row_dimensions[4].height = 15
|
||||
ws.row_dimensions[5].height = 15.75
|
||||
|
||||
@@ -3463,27 +3683,25 @@ async def generate_combined_from_preview(
|
||||
cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner)
|
||||
ws.row_dimensions[6].height = 39
|
||||
|
||||
# Data rows starting at row 7
|
||||
data_start_row = 7
|
||||
parsed_rows_p = []
|
||||
lmax_vals = []
|
||||
ln1_vals = []
|
||||
ln2_vals = []
|
||||
parsed_rows = []
|
||||
lmax_vals, ln1_vals, ln2_vals = [], [], []
|
||||
|
||||
for row_idx, row in enumerate(rows):
|
||||
for row_idx, row in enumerate(day_rows):
|
||||
dr = data_start_row + row_idx
|
||||
is_last = (row_idx == len(rows) - 1)
|
||||
is_last = (row_idx == len(day_rows) - 1)
|
||||
b_left = last_left if is_last else data_left
|
||||
b_inner = last_inner if is_last else data_inner
|
||||
b_right = last_right if is_last else data_right
|
||||
|
||||
test_num = row[0] if len(row) > 0 else row_idx + 1
|
||||
date_val = row[1] if len(row) > 1 else ''
|
||||
date_val = _fmt_date(row[1]) if len(row) > 1 and row[1] else ''
|
||||
time_val = row[2] if len(row) > 2 else ''
|
||||
lmax = row[3] if len(row) > 3 else ''
|
||||
ln1 = row[4] if len(row) > 4 else ''
|
||||
ln2 = row[5] if len(row) > 5 else ''
|
||||
comment = row[6] if len(row) > 6 else ''
|
||||
row_period = row[7] if len(row) > 7 else '' # hidden period_type from session
|
||||
|
||||
c = ws.cell(row=dr, column=1, value=test_num)
|
||||
c.font = f_data; c.alignment = center_a; c.border = b_left
|
||||
@@ -3508,20 +3726,11 @@ async def generate_combined_from_preview(
|
||||
if isinstance(ln2, (int, float)):
|
||||
ln2_vals.append(ln2)
|
||||
|
||||
# Parse time for evening/nighttime stats
|
||||
if time_val and isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)):
|
||||
try:
|
||||
try:
|
||||
row_dt = datetime.strptime(str(time_val), '%H:%M')
|
||||
except ValueError:
|
||||
row_dt = datetime.strptime(str(time_val), '%H:%M:%S')
|
||||
parsed_rows_p.append((row_dt, float(lmax), float(ln1), float(ln2)))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)):
|
||||
parsed_rows.append((row_period, time_val, float(lmax), float(ln1), float(ln2)))
|
||||
|
||||
data_end_row = data_start_row + len(rows) - 1
|
||||
data_end_row = data_start_row + len(day_rows) - 1
|
||||
|
||||
# Chart anchored at H4
|
||||
chart = LineChart()
|
||||
chart.title = f"{loc_name} - {final_title}"
|
||||
chart.style = 2
|
||||
@@ -3543,71 +3752,120 @@ async def generate_combined_from_preview(
|
||||
chart.series[2].graphicalProperties.line.solidFill = "0070C0"
|
||||
chart.series[2].graphicalProperties.line.width = 19050
|
||||
|
||||
_plot_border = GraphicalProperties()
|
||||
_plot_border.ln.solidFill = "000000"
|
||||
_plot_border.ln.w = 12700
|
||||
chart.plot_area.spPr = _plot_border
|
||||
ws.add_chart(chart, "H4")
|
||||
|
||||
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
|
||||
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
|
||||
note1.font = f_data; note1.alignment = left_a
|
||||
ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14)
|
||||
note2 = ws.cell(row=29, column=9, value="for each specified range of time intervals.")
|
||||
note2.font = f_data; note2.alignment = left_a
|
||||
ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14)
|
||||
|
||||
# Table header row 31
|
||||
med = Side(style='medium')
|
||||
tbl_top_left = Border(left=med, right=Side(style='thin'), top=med, bottom=Side(style='thin'))
|
||||
tbl_top_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=med, bottom=Side(style='thin'))
|
||||
tbl_top_right = Border(left=Side(style='thin'), right=med, top=med, bottom=Side(style='thin'))
|
||||
tbl_mid_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
||||
tbl_mid_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
||||
tbl_mid_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=Side(style='thin'))
|
||||
tbl_bot_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=med)
|
||||
tbl_bot_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=med)
|
||||
tbl_bot_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=med)
|
||||
|
||||
hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
|
||||
|
||||
# Header row: blank | Evening | Nighttime
|
||||
c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold
|
||||
c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)")
|
||||
c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||
c.border = tbl_top_mid; c.fill = hdr_fill_tbl
|
||||
ws.merge_cells(start_row=31, start_column=10, end_row=31, end_column=11)
|
||||
c = ws.cell(row=31, column=12, value="Nighttime (10PM to 7AM)")
|
||||
c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||
c.border = tbl_top_right; c.fill = hdr_fill_tbl
|
||||
ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13)
|
||||
ws.row_dimensions[31].height = 15
|
||||
def _avg(vals): return round(sum(vals) / len(vals), 1) if vals else None
|
||||
def _max(vals): return round(max(vals), 1) if vals else None
|
||||
|
||||
evening_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if 19 <= dt.hour < 22]
|
||||
nighttime_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if dt.hour >= 22 or dt.hour < 7]
|
||||
# --- Period bucketing ------------------------------------------------
|
||||
# For night sessions: split into Evening (7PM–10PM) and Nighttime (10PM–7AM).
|
||||
# For day sessions: single Daytime bucket.
|
||||
PERIOD_TYPE_IS_DAY = {"weekday_day", "weekend_day"}
|
||||
PERIOD_TYPE_IS_NIGHT = {"weekday_night", "weekend_night"}
|
||||
|
||||
def _avg_p(vals): return round(sum(vals) / len(vals), 1) if vals else None
|
||||
def _max_p(vals): return round(max(vals), 1) if vals else None
|
||||
day_rows_data = []
|
||||
evening_rows_data = []
|
||||
night_rows_data = []
|
||||
|
||||
def write_stat_p(row_num, label, eve_val, night_val, is_last=False):
|
||||
bl = tbl_bot_left if is_last else tbl_mid_left
|
||||
bm = tbl_bot_mid if is_last else tbl_mid_mid
|
||||
br = tbl_bot_right if is_last else tbl_mid_right
|
||||
lbl = ws.cell(row=row_num, column=9, value=label)
|
||||
lbl.font = f_data; lbl.border = bl
|
||||
for pt, time_v, lmx, l1, l2 in parsed_rows:
|
||||
if pt in PERIOD_TYPE_IS_DAY:
|
||||
day_rows_data.append((lmx, l1, l2))
|
||||
elif pt in PERIOD_TYPE_IS_NIGHT:
|
||||
# Split by time: Evening = 19:00–21:59, Nighttime = 22:00–06:59
|
||||
hour = 0
|
||||
if time_v and ':' in str(time_v):
|
||||
try:
|
||||
hour = int(str(time_v).split(':')[0])
|
||||
except ValueError:
|
||||
pass
|
||||
if 19 <= hour <= 21:
|
||||
evening_rows_data.append((lmx, l1, l2))
|
||||
else:
|
||||
night_rows_data.append((lmx, l1, l2))
|
||||
else:
|
||||
day_rows_data.append((lmx, l1, l2))
|
||||
|
||||
all_candidate_periods = [
|
||||
("Daytime (7AM to 10PM)", day_rows_data),
|
||||
("Evening (7PM to 10PM)", evening_rows_data),
|
||||
("Nighttime (10PM to 7AM)", night_rows_data),
|
||||
]
|
||||
active_periods = [(label, rows) for label, rows in all_candidate_periods if rows]
|
||||
if not active_periods:
|
||||
active_periods = [("Daytime (7AM to 10PM)", [])]
|
||||
|
||||
# --- Stats table — fixed position alongside the chart ---
|
||||
note1 = ws.cell(row=28, column=9,
|
||||
value="Note: Averages are calculated by determining the arithmetic average ")
|
||||
note1.font = f_data; note1.alignment = left_a
|
||||
ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14)
|
||||
note2 = ws.cell(row=29, column=9,
|
||||
value="for each specified range of time intervals.")
|
||||
note2.font = f_data; note2.alignment = left_a
|
||||
ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14)
|
||||
|
||||
for r in [28, 29, 30, 31, 32, 33, 34]:
|
||||
ws.row_dimensions[r].height = 15
|
||||
|
||||
tbl_hdr_row = 31
|
||||
tbl_data_row = 32
|
||||
|
||||
# Layout: col 9 = row label, then pairs: (10,11), (12,13), (14,15)
|
||||
num_periods = len(active_periods)
|
||||
period_start_cols = [10 + i * 2 for i in range(num_periods)]
|
||||
|
||||
def _hdr_border(i, n):
|
||||
return Border(
|
||||
left=med if i == 0 else thin,
|
||||
right=med if i == n - 1 else thin,
|
||||
top=med, bottom=thin,
|
||||
)
|
||||
|
||||
c = ws.cell(row=tbl_hdr_row, column=9, value=""); c.border = tbl_top_left; c.font = f_bold
|
||||
|
||||
for i, (period_label, _) in enumerate(active_periods):
|
||||
sc = period_start_cols[i]
|
||||
c = ws.cell(row=tbl_hdr_row, column=sc, value=period_label)
|
||||
c.font = f_bold
|
||||
c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=False)
|
||||
c.border = _hdr_border(i, num_periods)
|
||||
c.fill = hdr_fill_tbl
|
||||
ws.merge_cells(start_row=tbl_hdr_row, start_column=sc,
|
||||
end_row=tbl_hdr_row, end_column=sc + 1)
|
||||
|
||||
def write_stat_dynamic(row_num, row_label, period_vals_list, is_last=False):
|
||||
lbl = ws.cell(row=row_num, column=9, value=row_label)
|
||||
lbl.font = f_data; lbl.border = tbl_bot_left if is_last else tbl_mid_left
|
||||
lbl.alignment = Alignment(horizontal='left', vertical='center')
|
||||
ev_str = f"{eve_val} dBA" if eve_val is not None else ""
|
||||
ev = ws.cell(row=row_num, column=10, value=ev_str)
|
||||
ev.font = f_bold; ev.border = bm
|
||||
ev.alignment = Alignment(horizontal='center', vertical='center')
|
||||
ws.merge_cells(start_row=row_num, start_column=10, end_row=row_num, end_column=11)
|
||||
ni_str = f"{night_val} dBA" if night_val is not None else ""
|
||||
ni = ws.cell(row=row_num, column=12, value=ni_str)
|
||||
ni.font = f_bold; ni.border = br
|
||||
ni.alignment = Alignment(horizontal='center', vertical='center')
|
||||
ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13)
|
||||
n = len(period_vals_list)
|
||||
for i, val in enumerate(period_vals_list):
|
||||
sc = period_start_cols[i]
|
||||
val_str = f"{val} dBA" if val is not None else ""
|
||||
c = ws.cell(row=row_num, column=sc, value=val_str)
|
||||
c.font = f_bold
|
||||
c.alignment = Alignment(horizontal='center', vertical='center')
|
||||
c.border = Border(
|
||||
left=med if i == 0 else thin,
|
||||
right=med if i == n - 1 else thin,
|
||||
top=tbl_bot_mid.top if is_last else tbl_mid_mid.top,
|
||||
bottom=tbl_bot_mid.bottom if is_last else tbl_mid_mid.bottom,
|
||||
)
|
||||
ws.merge_cells(start_row=row_num, start_column=sc,
|
||||
end_row=row_num, end_column=sc + 1)
|
||||
|
||||
write_stat_p(32, "LAmax", _max_p([v[0] for v in evening_p]), _max_p([v[0] for v in nighttime_p]))
|
||||
write_stat_p(33, "LA01 Average",_avg_p([v[1] for v in evening_p]), _avg_p([v[1] for v in nighttime_p]))
|
||||
write_stat_p(34, "LA10 Average",_avg_p([v[2] for v in evening_p]), _avg_p([v[2] for v in nighttime_p]), is_last=True)
|
||||
write_stat_dynamic(tbl_data_row, "LAmax",
|
||||
[_max([v[0] for v in rows]) for _, rows in active_periods])
|
||||
write_stat_dynamic(tbl_data_row + 1, "LA01 Average",
|
||||
[_avg([v[1] for v in rows]) for _, rows in active_periods])
|
||||
write_stat_dynamic(tbl_data_row + 2, "LA10 Average",
|
||||
[_avg([v[2] for v in rows]) for _, rows in active_periods], is_last=True)
|
||||
|
||||
# Page setup: portrait, letter
|
||||
ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False)
|
||||
ws.page_setup.orientation = 'portrait'
|
||||
ws.page_setup.paperSize = 1
|
||||
@@ -3618,50 +3876,94 @@ async def generate_combined_from_preview(
|
||||
ws.page_margins.header = 0.5
|
||||
ws.page_margins.footer = 0.5
|
||||
|
||||
all_location_summaries.append({
|
||||
return {
|
||||
'location': loc_name,
|
||||
'samples': len(rows),
|
||||
'samples': len(day_rows),
|
||||
'lmax_avg': round(sum(lmax_vals) / len(lmax_vals), 1) if lmax_vals else None,
|
||||
'ln1_avg': round(sum(ln1_vals) / len(ln1_vals), 1) if ln1_vals else None,
|
||||
'ln2_avg': round(sum(ln2_vals) / len(ln2_vals), 1) if ln2_vals else None,
|
||||
})
|
||||
}
|
||||
|
||||
# Summary sheet
|
||||
thin_border = Border(left=Side(style='thin'), right=Side(style='thin'),
|
||||
top=Side(style='thin'), bottom=Side(style='thin'))
|
||||
summary_ws = wb.create_sheet(title="Summary", index=0)
|
||||
summary_ws['A1'] = f"{report_title} - {project_name} - Summary"
|
||||
def _build_summary_sheet(wb, day_label, project_name, loc_summaries):
|
||||
summary_ws = wb.create_sheet(title="Summary")
|
||||
summary_ws['A1'] = f"{report_title} - {project_name} - {day_label}"
|
||||
summary_ws['A1'].font = f_title
|
||||
summary_ws.merge_cells('A1:E1')
|
||||
|
||||
summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg']
|
||||
for col, header in enumerate(summary_headers, 1):
|
||||
cell = summary_ws.cell(row=3, column=col, value=header)
|
||||
cell.font = f_bold
|
||||
cell.fill = hdr_fill
|
||||
cell.border = thin_border
|
||||
|
||||
cell.font = f_bold; cell.fill = hdr_fill; cell.border = thin_border
|
||||
for i, width in enumerate([30, 10, 12, 12, 12], 1):
|
||||
summary_ws.column_dimensions[get_column_letter(i)].width = width
|
||||
for idx, s in enumerate(loc_summaries, 4):
|
||||
summary_ws.cell(row=idx, column=1, value=s['location']).border = thin_border
|
||||
summary_ws.cell(row=idx, column=2, value=s['samples']).border = thin_border
|
||||
summary_ws.cell(row=idx, column=3, value=s['lmax_avg'] or '-').border = thin_border
|
||||
summary_ws.cell(row=idx, column=4, value=s['ln1_avg'] or '-').border = thin_border
|
||||
summary_ws.cell(row=idx, column=5, value=s['ln2_avg'] or '-').border = thin_border
|
||||
|
||||
for idx, loc_summary in enumerate(all_location_summaries, 4):
|
||||
summary_ws.cell(row=idx, column=1, value=loc_summary['location']).border = thin_border
|
||||
summary_ws.cell(row=idx, column=2, value=loc_summary['samples']).border = thin_border
|
||||
summary_ws.cell(row=idx, column=3, value=loc_summary['lmax_avg'] or '-').border = thin_border
|
||||
summary_ws.cell(row=idx, column=4, value=loc_summary['ln1_avg'] or '-').border = thin_border
|
||||
summary_ws.cell(row=idx, column=5, value=loc_summary['ln2_avg'] or '-').border = thin_border
|
||||
# ----------------------------------------------------------------
|
||||
# Build one workbook per session (each location entry is one session)
|
||||
# ----------------------------------------------------------------
|
||||
if not locations:
|
||||
raise HTTPException(status_code=400, detail="No location data provided")
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip().replace(' ', '_')
|
||||
final_title = f"{report_title} - {project_name}"
|
||||
|
||||
project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip()
|
||||
filename = f"{project_name_clean}_combined_report.xlsx".replace(' ', '_')
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for loc_info in locations:
|
||||
loc_name = loc_info.get("location_name", "Unknown")
|
||||
session_label = loc_info.get("session_label", "")
|
||||
period_type = loc_info.get("period_type", "")
|
||||
started_at_str = loc_info.get("started_at", "")
|
||||
rows = loc_info.get("spreadsheet_data", [])
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
# Re-number interval # sequentially
|
||||
for i, row in enumerate(rows):
|
||||
if len(row) > 0:
|
||||
row[0] = i + 1
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
wb.remove(wb.active)
|
||||
|
||||
safe_sheet = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31]
|
||||
ws = wb.create_sheet(title=safe_sheet)
|
||||
summary = _build_location_sheet(ws, loc_name, rows, final_title)
|
||||
|
||||
# Derive a date label for the summary sheet from started_at or first row
|
||||
day_label = session_label or loc_name
|
||||
if started_at_str:
|
||||
try:
|
||||
_dt = datetime.fromisoformat(started_at_str)
|
||||
day_label = _dt.strftime('%-m/%-d/%Y')
|
||||
if session_label:
|
||||
day_label = session_label
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_build_summary_sheet(wb, day_label, project_name, [summary])
|
||||
|
||||
xlsx_buf = io.BytesIO()
|
||||
wb.save(xlsx_buf)
|
||||
xlsx_buf.seek(0)
|
||||
|
||||
# Build a clean filename from label or location+date
|
||||
label_clean = session_label or loc_name
|
||||
label_clean = "".join(c for c in label_clean if c.isalnum() or c in (' ', '-', '_', '/')).strip().replace(' ', '_').replace('/', '-')
|
||||
xlsx_name = f"{label_clean}_{project_name_clean}_report.xlsx"
|
||||
zf.writestr(xlsx_name, xlsx_buf.read())
|
||||
|
||||
zip_buffer.seek(0)
|
||||
zip_filename = f"{project_name_clean}_reports.zip"
|
||||
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||
zip_buffer,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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":
|
||||
|
||||
133
backend/routers/watcher_manager.py
Normal file
133
backend/routers/watcher_manager.py
Normal 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,
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
37
migrate_watcher_agents.py
Normal 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()
|
||||
12
rebuild-prod.sh
Executable file
12
rebuild-prod.sh
Executable 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"
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
273
templates/admin_watchers.html
Normal file
273
templates/admin_watchers.html
Normal 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 — 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 %}
|
||||
@@ -26,7 +26,7 @@
|
||||
<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 Excel
|
||||
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">
|
||||
@@ -187,7 +187,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const el = document.getElementById('spreadsheet-' + idx);
|
||||
if (!el) return;
|
||||
const opts = Object.assign({}, jssOptions, { data: loc.spreadsheet_data });
|
||||
spreadsheets[loc.location_name] = jspreadsheet(el, opts);
|
||||
spreadsheets[idx] = jspreadsheet(el, opts);
|
||||
});
|
||||
if (allLocationData.length > 0) {
|
||||
switchTab(0);
|
||||
@@ -228,9 +228,8 @@ function switchTab(idx) {
|
||||
}
|
||||
|
||||
// Refresh jspreadsheet rendering after showing panel
|
||||
const loc = allLocationData[idx];
|
||||
if (loc && spreadsheets[loc.location_name]) {
|
||||
try { spreadsheets[loc.location_name].updateTable(); } catch(e) {}
|
||||
if (spreadsheets[idx]) {
|
||||
try { spreadsheets[idx].updateTable(); } catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,13 +237,17 @@ 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...';
|
||||
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) {
|
||||
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[loc.location_name] ? spreadsheets[loc.location_name].getData() : loc.spreadsheet_data,
|
||||
spreadsheet_data: spreadsheets[idx] ? spreadsheets[idx].getData() : loc.spreadsheet_data,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -268,7 +271,7 @@ async function downloadCombinedReport() {
|
||||
a.href = url;
|
||||
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = 'combined_report.xlsx';
|
||||
let filename = 'combined_reports.zip';
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename="(.+)"/);
|
||||
if (match) filename = match[1];
|
||||
|
||||
@@ -74,105 +74,134 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time Filter 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">Time Filter</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Applied to all locations. Leave blank to include all data.</p>
|
||||
|
||||
<!-- Preset Buttons -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button type="button" onclick="setTimePreset('night')" data-preset="night"
|
||||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
Night 7PM – 7AM
|
||||
</button>
|
||||
<button type="button" onclick="setTimePreset('day')" data-preset="day"
|
||||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
Day 7AM – 7PM
|
||||
</button>
|
||||
<button type="button" onclick="setTimePreset('all')" data-preset="all"
|
||||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-700 transition-colors">
|
||||
All Day
|
||||
</button>
|
||||
<button type="button" onclick="setTimePreset('custom')" data-preset="custom"
|
||||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Time Inputs -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="start-time" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Time</label>
|
||||
<input type="time" id="start-time" value=""
|
||||
onchange="updatePresetButtons()"
|
||||
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="end-time" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Time</label>
|
||||
<input type="time" id="end-time" value=""
|
||||
onchange="updatePresetButtons()"
|
||||
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>
|
||||
|
||||
<!-- Date Range -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Date Range <span class="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="start-date" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From</label>
|
||||
<input type="date" id="start-date" value=""
|
||||
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="end-date" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">To</label>
|
||||
<input type="date" id="end-date" value=""
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- Locations Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Locations to Include</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
<span id="selected-count">{{ locations|length }}</span> of {{ locations|length }} selected
|
||||
</p>
|
||||
</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="selectAll()" class="text-emerald-600 dark:text-emerald-400 hover:underline">Select All</button>
|
||||
<button type="button" onclick="deselectAll()" class="text-gray-500 dark:text-gray-400 hover:underline">Deselect All</button>
|
||||
<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 %}
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{% for loc in locations %}
|
||||
<label class="flex items-center gap-3 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-slate-700/50 px-2 rounded-md transition-colors">
|
||||
<input type="checkbox" name="location" value="{{ loc.name }}" checked
|
||||
onchange="updateSelectedCount()"
|
||||
class="h-4 w-4 text-emerald-600 border-gray-300 dark:border-gray-600 rounded focus:ring-emerald-500">
|
||||
<span class="flex-1 text-sm text-gray-900 dark:text-white font-medium">{{ loc.name }}</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ loc.file_count }} file{{ 's' if loc.file_count != 1 else '' }}</span>
|
||||
</label>
|
||||
{% 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-8 text-gray-500 dark:text-gray-400">
|
||||
<p>No Leq measurement files found in this project.</p>
|
||||
<p class="text-sm mt-1">Upload RND files with '_Leq_' in the filename to generate reports.</p>
|
||||
<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="/api/projects/{{ project_id }}"
|
||||
<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>
|
||||
@@ -191,180 +220,173 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let reportTemplates = [];
|
||||
const PROJECT_ID = '{{ project_id }}';
|
||||
|
||||
// ---- Template management (same as rnd_viewer.html) ----
|
||||
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 response = await fetch('/api/report-templates?project_id={{ project_id }}');
|
||||
if (response.ok) {
|
||||
reportTemplates = await response.json();
|
||||
const resp = await fetch('/api/report-templates?project_id=' + PROJECT_ID);
|
||||
if (resp.ok) {
|
||||
reportTemplates = await resp.json();
|
||||
populateTemplateDropdown();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading templates:', error);
|
||||
}
|
||||
} 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(template => {
|
||||
const option = document.createElement('option');
|
||||
option.value = template.id;
|
||||
option.textContent = template.name;
|
||||
option.dataset.config = JSON.stringify(template);
|
||||
select.appendChild(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 selectedOption = select.options[select.selectedIndex];
|
||||
if (!selectedOption.value) return;
|
||||
const template = JSON.parse(selectedOption.dataset.config);
|
||||
if (template.report_title) document.getElementById('report-title').value = template.report_title;
|
||||
if (template.start_time) document.getElementById('start-time').value = template.start_time;
|
||||
if (template.end_time) document.getElementById('end-time').value = template.end_time;
|
||||
if (template.start_date) document.getElementById('start-date').value = template.start_date;
|
||||
if (template.end_date) document.getElementById('end-date').value = template.end_date;
|
||||
updatePresetButtons();
|
||||
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 templateData = {
|
||||
name: name,
|
||||
project_id: '{{ project_id }}',
|
||||
const data = {
|
||||
name,
|
||||
project_id: PROJECT_ID,
|
||||
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
||||
start_time: document.getElementById('start-time').value || null,
|
||||
end_time: document.getElementById('end-time').value || null,
|
||||
start_date: document.getElementById('start-date').value || null,
|
||||
end_date: document.getElementById('end-date').value || null
|
||||
};
|
||||
try {
|
||||
const response = await fetch('/api/report-templates', {
|
||||
const resp = await fetch('/api/report-templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(templateData)
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (response.ok) {
|
||||
alert('Template saved successfully!');
|
||||
loadTemplates();
|
||||
} else {
|
||||
alert('Failed to save template');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error saving template: ' + error.message);
|
||||
}
|
||||
if (resp.ok) { alert('Template saved!'); loadTemplates(); }
|
||||
else alert('Failed to save template');
|
||||
} catch(e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
|
||||
// ---- Time preset buttons ----
|
||||
|
||||
function setTimePreset(preset) {
|
||||
const startTimeInput = document.getElementById('start-time');
|
||||
const endTimeInput = document.getElementById('end-time');
|
||||
|
||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.classList.remove('bg-emerald-600', 'text-white');
|
||||
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||||
});
|
||||
|
||||
switch (preset) {
|
||||
case 'night':
|
||||
startTimeInput.value = '19:00';
|
||||
endTimeInput.value = '07:00';
|
||||
break;
|
||||
case 'day':
|
||||
startTimeInput.value = '07:00';
|
||||
endTimeInput.value = '19:00';
|
||||
break;
|
||||
case 'all':
|
||||
startTimeInput.value = '';
|
||||
endTimeInput.value = '';
|
||||
break;
|
||||
case 'custom':
|
||||
break;
|
||||
}
|
||||
|
||||
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||||
activeBtn.classList.add('bg-emerald-600', 'text-white');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePresetButtons() {
|
||||
const startTime = document.getElementById('start-time').value;
|
||||
const endTime = document.getElementById('end-time').value;
|
||||
|
||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.classList.remove('bg-emerald-600', 'text-white');
|
||||
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||||
});
|
||||
|
||||
let preset = 'custom';
|
||||
if (startTime === '19:00' && endTime === '07:00') preset = 'night';
|
||||
else if (startTime === '07:00' && endTime === '19:00') preset = 'day';
|
||||
else if (!startTime && !endTime) preset = 'all';
|
||||
|
||||
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||||
activeBtn.classList.add('bg-emerald-600', 'text-white');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Location checkboxes ----
|
||||
|
||||
function updateSelectedCount() {
|
||||
const checked = document.querySelectorAll('input[name="location"]:checked').length;
|
||||
document.getElementById('selected-count').textContent = checked;
|
||||
document.getElementById('preview-btn').disabled = checked === 0;
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
document.querySelectorAll('input[name="location"]').forEach(cb => cb.checked = true);
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
document.querySelectorAll('input[name="location"]').forEach(cb => cb.checked = false);
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function getCheckedLocations() {
|
||||
return Array.from(document.querySelectorAll('input[name="location"]:checked')).map(cb => cb.value);
|
||||
}
|
||||
|
||||
// ---- Navigate to preview ----
|
||||
// ── Navigate to preview ───────────────────────────────────────────
|
||||
|
||||
function gotoPreview() {
|
||||
const checked = getCheckedLocations();
|
||||
const checked = Array.from(document.querySelectorAll('.session-cb:checked')).map(cb => cb.value);
|
||||
if (checked.length === 0) {
|
||||
alert('Please select at least one location.');
|
||||
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 || '',
|
||||
start_time: document.getElementById('start-time').value || '',
|
||||
end_time: document.getElementById('end-time').value || '',
|
||||
start_date: document.getElementById('start-date').value || '',
|
||||
end_date: document.getElementById('end-date').value || '',
|
||||
enabled_locations: checked.join(','),
|
||||
selected_sessions: checked.join(','),
|
||||
});
|
||||
|
||||
window.location.href = `/api/projects/{{ project_id }}/combined-report-preview?${params.toString()}`;
|
||||
window.location.href = `/api/projects/${PROJECT_ID}/combined-report-preview?${params.toString()}`;
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// ── Init ─────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateSelectionStats();
|
||||
loadTemplates();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,39 +640,73 @@ 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`;
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
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 response = await fetch(
|
||||
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
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)`);
|
||||
@@ -670,16 +715,30 @@ async function submitUpload() {
|
||||
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'}`;
|
||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
} catch (err) {
|
||||
status.textContent = `Error: ${err.message}`;
|
||||
} catch {
|
||||
status.textContent = 'Error: Unexpected server response';
|
||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
});
|
||||
|
||||
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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
<!-- 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>
|
||||
{% 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
|
||||
<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 }}')"
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -1642,45 +1654,110 @@ 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`;
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
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}%`;
|
||||
}
|
||||
});
|
||||
|
||||
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 response = await fetch(
|
||||
`/api/projects/{{ project_id }}/upload-all`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
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');
|
||||
|
||||
// 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>';
|
||||
@@ -1700,17 +1777,25 @@ async function submitUploadAll() {
|
||||
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'}`;
|
||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
} catch (err) {
|
||||
status.textContent = `Error: ${err.message}`;
|
||||
} catch {
|
||||
status.textContent = 'Error: Unexpected server response';
|
||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,19 +497,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checkboxes -->
|
||||
<div class="flex items-center gap-6 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<!-- 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="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>
|
||||
<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 -->
|
||||
<div>
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user