Compare commits
18 Commits
v0.7.0
...
5ea64c3561
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ea64c3561 | |||
| 1a87ff13c9 | |||
| 22c62c0729 | |||
| 0f47b69c92 | |||
| 76667454b3 | |||
|
|
15d962ba42 | ||
|
|
b571dc29bc | ||
|
|
e2c841d5d7 | ||
| cc94493331 | |||
|
|
5a5426cceb | ||
|
|
66eddd6fe2 | ||
|
|
c77794787c | ||
| 61c84bc71d | |||
| fbf7f2a65d | |||
|
|
202fcaf91c | ||
|
|
3a411d0a89 | ||
| 0c2186f5d8 | |||
| c138e8c6a0 |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Terra-View Specifics
|
# Terra-View Specifics
|
||||||
# Dev build counter (local only, never commit)
|
# Dev build counter (local only, never commit)
|
||||||
build_number.txt
|
build_number.txt
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
# SQLite database files
|
# SQLite database files
|
||||||
*.db
|
*.db
|
||||||
@@ -9,7 +10,7 @@ data/
|
|||||||
data-dev/
|
data-dev/
|
||||||
.aider*
|
.aider*
|
||||||
.aider*
|
.aider*
|
||||||
docker-compose.override.yml
|
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -219,3 +220,14 @@ marimo/_static/
|
|||||||
marimo/_lsp/
|
marimo/_lsp/
|
||||||
__marimo__/
|
__marimo__/
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
# Seismo Fleet Manager
|
||||||
|
# SQLite database files
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
/data/
|
||||||
|
/data-dev/
|
||||||
|
.aider*
|
||||||
|
.aider*
|
||||||
|
=======
|
||||||
|
>>>>>>> 0c2186f5d89d948b0357d674c0773a67a67d8027
|
||||||
|
|||||||
38
CHANGELOG.md
38
CHANGELOG.md
@@ -5,6 +5,44 @@ 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/),
|
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).
|
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
|
## [0.7.0] - 2026-03-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.7.0
|
# 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.
|
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
|
## Features
|
||||||
@@ -496,6 +496,11 @@ docker compose down -v
|
|||||||
|
|
||||||
## Release Highlights
|
## 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
|
### v0.7.0 — 2026-03-07
|
||||||
- **Project Status Management**: On-hold and archived project states with automatic cancellation of pending actions
|
- **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
|
- **Manual SD Card Upload**: Upload offline NRL/SLM data directly from SD card (ZIP or multi-file); auto-creates monitoring sessions from `.rnh` metadata
|
||||||
@@ -594,9 +599,13 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Current: 0.7.0** — Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
|
**Current: 0.8.0** — Watcher Manager admin page, live agent status refresh, watcher connectivity-based status (2026-03-18)
|
||||||
|
|
||||||
Previous: 0.6.1 — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
|
Previous: 0.7.1 — Out-for-calibration status, reservation modal, migration fixes (2026-03-12)
|
||||||
|
|
||||||
|
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.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.7.0"
|
VERSION = "0.8.0"
|
||||||
if ENVIRONMENT == "development":
|
if ENVIRONMENT == "development":
|
||||||
_build = os.getenv("BUILD_NUMBER", "0")
|
_build = os.getenv("BUILD_NUMBER", "0")
|
||||||
if _build and _build != "0":
|
if _build and _build != "0":
|
||||||
@@ -102,6 +102,9 @@ app.include_router(modem_dashboard.router)
|
|||||||
from backend.routers import settings
|
from backend.routers import settings
|
||||||
app.include_router(settings.router)
|
app.include_router(settings.router)
|
||||||
|
|
||||||
|
from backend.routers import watcher_manager
|
||||||
|
app.include_router(watcher_manager.router)
|
||||||
|
|
||||||
# Projects system routers
|
# Projects system routers
|
||||||
app.include_router(projects.router)
|
app.include_router(projects.router)
|
||||||
app.include_router(project_locations.router)
|
app.include_router(project_locations.router)
|
||||||
@@ -659,6 +662,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": unit_data.get("last", "Never"),
|
"last_seen": unit_data.get("last", "Never"),
|
||||||
"deployed": True,
|
"deployed": True,
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
"ignored": False,
|
"ignored": False,
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"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"),
|
"last_seen": unit_data.get("last", "Never"),
|
||||||
"deployed": False,
|
"deployed": False,
|
||||||
"retired": 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,
|
"ignored": False,
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
@@ -707,6 +737,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": "N/A",
|
"last_seen": "N/A",
|
||||||
"deployed": False,
|
"deployed": False,
|
||||||
"retired": True,
|
"retired": True,
|
||||||
|
"out_for_calibration": False,
|
||||||
"ignored": False,
|
"ignored": False,
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
@@ -731,6 +762,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": "N/A",
|
"last_seen": "N/A",
|
||||||
"deployed": False,
|
"deployed": False,
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
"ignored": True,
|
"ignored": True,
|
||||||
"note": unit_data.get("note", unit_data.get("reason", "")),
|
"note": unit_data.get("note", unit_data.get("reason", "")),
|
||||||
"device_type": unit_data.get("device_type", "unknown"),
|
"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
|
# Sort by status category, then by ID
|
||||||
def sort_key(unit):
|
def sort_key(unit):
|
||||||
# Priority: deployed (active) -> benched -> retired -> ignored
|
# Priority: deployed (active) -> benched -> out_for_calibration -> retired -> ignored
|
||||||
if unit["deployed"]:
|
if unit["deployed"]:
|
||||||
return (0, unit["id"])
|
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"])
|
return (1, unit["id"])
|
||||||
elif unit["retired"]:
|
elif unit["out_for_calibration"]:
|
||||||
return (2, unit["id"])
|
return (2, unit["id"])
|
||||||
else:
|
elif unit["retired"]:
|
||||||
return (3, unit["id"])
|
return (3, unit["id"])
|
||||||
|
else:
|
||||||
|
return (4, unit["id"])
|
||||||
|
|
||||||
units_list.sort(key=sort_key)
|
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__":
|
if __name__ == "__main__":
|
||||||
db_path = "./data/terra-view.db"
|
db_path = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
db_path = sys.argv[1]
|
db_path = sys.argv[1]
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def migrate(db_path: str):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
db_path = "./data/terra-view.db"
|
db_path = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
db_path = sys.argv[1]
|
db_path = sys.argv[1]
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class RosterUnit(Base):
|
|||||||
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
|
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
|
||||||
deployed = Column(Boolean, default=True)
|
deployed = Column(Boolean, default=True)
|
||||||
retired = Column(Boolean, default=False)
|
retired = Column(Boolean, default=False)
|
||||||
|
out_for_calibration = Column(Boolean, default=False)
|
||||||
note = Column(String, nullable=True)
|
note = Column(String, nullable=True)
|
||||||
project_id = Column(String, nullable=True)
|
project_id = Column(String, nullable=True)
|
||||||
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
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
|
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):
|
class IgnoredUnit(Base):
|
||||||
"""
|
"""
|
||||||
Ignored units: units that report but should be filtered out from unknown emitters.
|
Ignored units: units that report but should be filtered out from unknown emitters.
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ async def add_roster_unit(
|
|||||||
unit_type: str = Form("series3"),
|
unit_type: str = Form("series3"),
|
||||||
deployed: str = Form(None),
|
deployed: str = Form(None),
|
||||||
retired: str = Form(None),
|
retired: str = Form(None),
|
||||||
|
out_for_calibration: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
location: str = Form(None),
|
||||||
@@ -177,6 +178,7 @@ async def add_roster_unit(
|
|||||||
# Convert boolean strings to actual booleans
|
# Convert boolean strings to actual booleans
|
||||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired 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
|
# Convert port strings to integers
|
||||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
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,
|
unit_type=unit_type,
|
||||||
deployed=deployed_bool,
|
deployed=deployed_bool,
|
||||||
retired=retired_bool,
|
retired=retired_bool,
|
||||||
|
out_for_calibration=out_for_calibration_bool,
|
||||||
note=note,
|
note=note,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location=location,
|
location=location,
|
||||||
@@ -463,6 +466,7 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"unit_type": unit.unit_type,
|
"unit_type": unit.unit_type,
|
||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
|
"out_for_calibration": unit.out_for_calibration or False,
|
||||||
"note": unit.note or "",
|
"note": unit.note or "",
|
||||||
"project_id": unit.project_id or "",
|
"project_id": unit.project_id or "",
|
||||||
"location": unit.location or "",
|
"location": unit.location or "",
|
||||||
@@ -494,6 +498,7 @@ async def edit_roster_unit(
|
|||||||
unit_type: str = Form("series3"),
|
unit_type: str = Form("series3"),
|
||||||
deployed: str = Form(None),
|
deployed: str = Form(None),
|
||||||
retired: str = Form(None),
|
retired: str = Form(None),
|
||||||
|
out_for_calibration: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
location: str = Form(None),
|
||||||
@@ -535,6 +540,7 @@ async def edit_roster_unit(
|
|||||||
# Convert boolean strings to actual booleans
|
# Convert boolean strings to actual booleans
|
||||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired 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
|
# Convert port strings to integers
|
||||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
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_note = unit.note
|
||||||
old_deployed = unit.deployed
|
old_deployed = unit.deployed
|
||||||
old_retired = unit.retired
|
old_retired = unit.retired
|
||||||
|
old_out_for_calibration = unit.out_for_calibration
|
||||||
|
|
||||||
# Update all fields
|
# Update all fields
|
||||||
unit.device_type = device_type
|
unit.device_type = device_type
|
||||||
unit.unit_type = unit_type
|
unit.unit_type = unit_type
|
||||||
unit.deployed = deployed_bool
|
unit.deployed = deployed_bool
|
||||||
unit.retired = retired_bool
|
unit.retired = retired_bool
|
||||||
|
unit.out_for_calibration = out_for_calibration_bool
|
||||||
unit.note = note
|
unit.note = note
|
||||||
unit.project_id = project_id
|
unit.project_id = project_id
|
||||||
unit.location = location
|
unit.location = location
|
||||||
@@ -677,6 +685,11 @@ async def edit_roster_unit(
|
|||||||
old_status_text = "retired" if old_retired else "active"
|
old_status_text = "retired" if old_retired else "active"
|
||||||
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
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
|
# Handle cascade to paired device
|
||||||
cascaded_unit_id = None
|
cascaded_unit_id = None
|
||||||
if cascade_to_unit_id and cascade_to_unit_id.strip():
|
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)
|
total = len(seismos)
|
||||||
deployed = sum(1 for s in seismos if s.deployed)
|
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
|
# Count modems assigned to deployed seismographs
|
||||||
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
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,
|
"total": total,
|
||||||
"deployed": deployed,
|
"deployed": deployed,
|
||||||
"benched": benched,
|
"benched": benched,
|
||||||
|
"out_for_calibration": out_for_calibration,
|
||||||
"with_modem": with_modem,
|
"with_modem": with_modem,
|
||||||
"without_modem": without_modem
|
"without_modem": without_modem
|
||||||
}
|
}
|
||||||
@@ -77,7 +79,9 @@ async def get_seismo_units(
|
|||||||
if status == "deployed":
|
if status == "deployed":
|
||||||
query = query.filter(RosterUnit.deployed == True)
|
query = query.filter(RosterUnit.deployed == True)
|
||||||
elif status == "benched":
|
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
|
# Apply modem filter
|
||||||
if modem == "with":
|
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 typing import Optional, List
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import Emitter
|
from backend.models import Emitter, WatcherAgent
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -107,6 +107,35 @@ def get_fleet_status(db: Session = Depends(get_db)):
|
|||||||
emitters = db.query(Emitter).all()
|
emitters = db.query(Emitter).all()
|
||||||
return emitters
|
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)
|
# series3v1.1 Standardized Heartbeat Schema (multi-unit)
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
@@ -120,6 +149,11 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
source = payload.get("source_id")
|
source = payload.get("source_id")
|
||||||
units = payload.get("units", [])
|
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("\n=== Series 3 Heartbeat ===")
|
||||||
print("Source:", source)
|
print("Source:", source)
|
||||||
@@ -182,13 +216,27 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
results.append({"unit": uid, "status": status})
|
results.append({"unit": uid, "status": status})
|
||||||
|
|
||||||
|
if source:
|
||||||
|
_upsert_watcher_agent(db, source, "series3_watcher", version,
|
||||||
|
client_ip, log_tail_str, "ok")
|
||||||
|
|
||||||
db.commit()
|
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 {
|
return {
|
||||||
"message": "Heartbeat processed",
|
"message": "Heartbeat processed",
|
||||||
"source": source,
|
"source": source,
|
||||||
"units_processed": len(results),
|
"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")
|
source = payload.get("source", "series4_emitter")
|
||||||
units = payload.get("units", [])
|
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("\n=== Series 4 Heartbeat ===")
|
||||||
print("Source:", source)
|
print("Source:", source)
|
||||||
@@ -276,11 +329,25 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
results.append({"unit": uid, "status": status})
|
results.append({"unit": uid, "status": status})
|
||||||
|
|
||||||
|
if source:
|
||||||
|
_upsert_watcher_agent(db, source, "series4_watcher", version,
|
||||||
|
client_ip, log_tail_str, "ok")
|
||||||
|
|
||||||
db.commit()
|
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 {
|
return {
|
||||||
"message": "Heartbeat processed",
|
"message": "Heartbeat processed",
|
||||||
"source": source,
|
"source": source,
|
||||||
"units_processed": len(results),
|
"units_processed": len(results),
|
||||||
"results": results
|
"results": results,
|
||||||
|
"update_available": update_available,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ def emit_status_snapshot():
|
|||||||
age = "N/A"
|
age = "N/A"
|
||||||
last_seen = None
|
last_seen = None
|
||||||
fname = ""
|
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:
|
else:
|
||||||
if e:
|
if e:
|
||||||
last_seen = ensure_utc(e.last_seen)
|
last_seen = ensure_utc(e.last_seen)
|
||||||
@@ -103,6 +109,7 @@ def emit_status_snapshot():
|
|||||||
"deployed": r.deployed,
|
"deployed": r.deployed,
|
||||||
"note": r.note or "",
|
"note": r.note or "",
|
||||||
"retired": r.retired,
|
"retired": r.retired,
|
||||||
|
"out_for_calibration": r.out_for_calibration or False,
|
||||||
# Device type and type-specific fields
|
# Device type and type-specific fields
|
||||||
"device_type": r.device_type or "seismograph",
|
"device_type": r.device_type or "seismograph",
|
||||||
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||||
@@ -133,6 +140,7 @@ def emit_status_snapshot():
|
|||||||
"deployed": False, # default
|
"deployed": False, # default
|
||||||
"note": "",
|
"note": "",
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
# Device type and type-specific fields (defaults for unknown units)
|
# Device type and type-specific fields (defaults for unknown units)
|
||||||
"device_type": "seismograph", # default
|
"device_type": "seismograph", # default
|
||||||
"last_calibrated": None,
|
"last_calibrated": None,
|
||||||
@@ -179,12 +187,12 @@ def emit_status_snapshot():
|
|||||||
# Separate buckets for UI
|
# Separate buckets for UI
|
||||||
active_units = {
|
active_units = {
|
||||||
uid: u for uid, u in units.items()
|
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 = {
|
benched_units = {
|
||||||
uid: u for uid, u in units.items()
|
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 = {
|
retired_units = {
|
||||||
@@ -192,6 +200,11 @@ def emit_status_snapshot():
|
|||||||
if u["retired"]
|
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 - emitters that aren't in the roster and aren't ignored
|
||||||
unknown_units = {
|
unknown_units = {
|
||||||
uid: u for uid, u in units.items()
|
uid: u for uid, u in units.items()
|
||||||
@@ -204,12 +217,14 @@ def emit_status_snapshot():
|
|||||||
"active": active_units,
|
"active": active_units,
|
||||||
"benched": benched_units,
|
"benched": benched_units,
|
||||||
"retired": retired_units,
|
"retired": retired_units,
|
||||||
|
"out_for_calibration": out_for_calibration_units,
|
||||||
"unknown": unknown_units,
|
"unknown": unknown_units,
|
||||||
"summary": {
|
"summary": {
|
||||||
"total": len(active_units) + len(benched_units),
|
"total": len(active_units) + len(benched_units),
|
||||||
"active": len(active_units),
|
"active": len(active_units),
|
||||||
"benched": len(benched_units),
|
"benched": len(benched_units),
|
||||||
"retired": len(retired_units),
|
"retired": len(retired_units),
|
||||||
|
"out_for_calibration": len(out_for_calibration_units),
|
||||||
"unknown": len(unknown_units),
|
"unknown": len(unknown_units),
|
||||||
# Status counts only for deployed units (active_units)
|
# Status counts only for deployed units (active_units)
|
||||||
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
|
"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
|
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
|
# Register Jinja filters and globals
|
||||||
templates.env.filters["local_datetime"] = jinja_local_datetime
|
templates.env.filters["local_datetime"] = jinja_local_datetime
|
||||||
templates.env.filters["local_time"] = jinja_local_time
|
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["timezone_abbr"] = jinja_timezone_abbr
|
||||||
templates.env.globals["get_user_timezone"] = get_user_timezone
|
templates.env.globals["get_user_timezone"] = get_user_timezone
|
||||||
templates.env.globals["same_date"] = jinja_same_date
|
templates.env.globals["same_date"] = jinja_same_date
|
||||||
|
templates.env.filters["log_tail_display"] = jinja_log_tail_display
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
# --- TERRA-VIEW PRODUCTION ---
|
|
||||||
terra-view:
|
terra-view:
|
||||||
build: .
|
build: .
|
||||||
container_name: terra-view
|
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8001:8001"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -29,7 +27,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../slmm
|
context: ../slmm
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: slmm
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
volumes:
|
volumes:
|
||||||
- ../slmm/data:/app/data
|
- ../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()
|
||||||
0
rebuild-prod.sh
Normal file → Executable file
0
rebuild-prod.sh
Normal file → Executable file
@@ -8,7 +8,7 @@ import sys
|
|||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.orm import sessionmaker
|
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):
|
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 %}
|
||||||
@@ -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="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="p-6">
|
||||||
<div class="flex items-center justify-between mb-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">
|
<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">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="hidden" id="editing-reservation-id" value="">
|
||||||
|
|
||||||
<form id="reservation-form" onsubmit="submitReservation(event)">
|
<form id="reservation-form" onsubmit="submitReservation(event)">
|
||||||
<!-- Name -->
|
<!-- 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">
|
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</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">
|
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
Create Reservation
|
Create Reservation
|
||||||
</button>
|
</button>
|
||||||
@@ -600,27 +601,83 @@ function closeDayPanel() {
|
|||||||
let reservationModeActive = false;
|
let reservationModeActive = false;
|
||||||
|
|
||||||
function openReservationModal() {
|
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');
|
document.getElementById('reservation-modal').classList.remove('hidden');
|
||||||
reservationModeActive = true;
|
reservationModeActive = true;
|
||||||
// Show reservation legend, hide main legend
|
|
||||||
document.getElementById('main-legend').classList.add('md:hidden');
|
document.getElementById('main-legend').classList.add('md:hidden');
|
||||||
document.getElementById('main-legend').classList.remove('md:flex');
|
document.getElementById('main-legend').classList.remove('md:flex');
|
||||||
document.getElementById('reservation-legend').classList.remove('md:hidden');
|
document.getElementById('reservation-legend').classList.remove('md:hidden');
|
||||||
document.getElementById('reservation-legend').classList.add('md:flex');
|
document.getElementById('reservation-legend').classList.add('md:flex');
|
||||||
// Trigger availability update
|
|
||||||
updateCalendarAvailability();
|
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() {
|
function closeReservationModal() {
|
||||||
document.getElementById('reservation-modal').classList.add('hidden');
|
document.getElementById('reservation-modal').classList.add('hidden');
|
||||||
document.getElementById('reservation-form').reset();
|
document.getElementById('reservation-form').reset();
|
||||||
|
document.getElementById('editing-reservation-id').value = '';
|
||||||
reservationModeActive = false;
|
reservationModeActive = false;
|
||||||
// Restore main legend
|
|
||||||
document.getElementById('main-legend').classList.remove('md:hidden');
|
document.getElementById('main-legend').classList.remove('md:hidden');
|
||||||
document.getElementById('main-legend').classList.add('md:flex');
|
document.getElementById('main-legend').classList.add('md:flex');
|
||||||
document.getElementById('reservation-legend').classList.add('md:hidden');
|
document.getElementById('reservation-legend').classList.add('md:hidden');
|
||||||
document.getElementById('reservation-legend').classList.remove('md:flex');
|
document.getElementById('reservation-legend').classList.remove('md:flex');
|
||||||
// Reset calendar colors
|
|
||||||
resetCalendarColors();
|
resetCalendarColors();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,6 +809,7 @@ async function submitReservation(event) {
|
|||||||
const form = event.target;
|
const form = event.target;
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
const endDateTbd = formData.get('end_date_tbd') === 'on';
|
const endDateTbd = formData.get('end_date_tbd') === 'on';
|
||||||
|
const editingId = document.getElementById('editing-reservation-id').value;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
name: formData.get('name'),
|
name: formData.get('name'),
|
||||||
@@ -766,7 +824,6 @@ async function submitReservation(event) {
|
|||||||
notes: formData.get('notes') || null
|
notes: formData.get('notes') || null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate: need either end_date or TBD checked
|
|
||||||
if (!data.end_date && !data.end_date_tbd) {
|
if (!data.end_date && !data.end_date_tbd) {
|
||||||
alert('Please enter an end date or check "TBD / Ongoing"');
|
alert('Please enter an end date or check "TBD / Ongoing"');
|
||||||
return;
|
return;
|
||||||
@@ -778,9 +835,15 @@ async function submitReservation(event) {
|
|||||||
data.unit_ids = formData.getAll('unit_ids');
|
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 {
|
try {
|
||||||
const response = await fetch('/api/fleet-calendar/reservations', {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
@@ -789,14 +852,13 @@ async function submitReservation(event) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
closeReservationModal();
|
closeReservationModal();
|
||||||
// Reload the page to refresh calendar
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
alert('Error creating reservation: ' + (result.detail || 'Unknown error'));
|
alert(`Error ${isEdit ? 'saving' : 'creating'} reservation: ` + (result.detail || 'Unknown error'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
alert('Error creating reservation');
|
alert(`Error ${isEdit ? 'saving' : 'creating'} reservation`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
{% for unit in units %}
|
{% for unit in units %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
data-device-type="{{ unit.device_type }}"
|
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-health="{{ unit.status }}"
|
||||||
data-id="{{ unit.id }}"
|
data-id="{{ unit.id }}"
|
||||||
data-type="{{ unit.device_type }}"
|
data-type="{{ unit.device_type }}"
|
||||||
@@ -60,7 +60,9 @@
|
|||||||
data-note="{{ unit.note if unit.note else '' }}">
|
data-note="{{ unit.note if unit.note else '' }}">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="flex items-center space-x-2">
|
<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>
|
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||||
{% elif unit.status == 'OK' %}
|
{% elif unit.status == 'OK' %}
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||||
@@ -72,6 +74,8 @@
|
|||||||
|
|
||||||
{% if unit.deployed %}
|
{% if unit.deployed %}
|
||||||
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
<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 %}
|
{% else %}
|
||||||
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -203,14 +207,16 @@
|
|||||||
<div class="unit-card device-card"
|
<div class="unit-card device-card"
|
||||||
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||||
data-device-type="{{ unit.device_type }}"
|
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-health="{{ unit.status }}"
|
||||||
data-unit-id="{{ unit.id }}"
|
data-unit-id="{{ unit.id }}"
|
||||||
data-age="{{ unit.age }}">
|
data-age="{{ unit.age }}">
|
||||||
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="flex items-center gap-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>
|
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||||
{% elif unit.status == 'OK' %}
|
{% elif unit.status == 'OK' %}
|
||||||
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
<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>
|
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="px-3 py-1 rounded-full text-xs font-medium
|
<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 == '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
|
{% 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
|
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||||
{% endif %}">
|
{% 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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -279,6 +286,10 @@
|
|||||||
<span class="text-xs text-blue-600 dark:text-blue-400">
|
<span class="text-xs text-blue-600 dark:text-blue-400">
|
||||||
⚡ Deployed
|
⚡ Deployed
|
||||||
</span>
|
</span>
|
||||||
|
{% elif unit.out_for_calibration %}
|
||||||
|
<span class="text-xs text-purple-600 dark:text-purple-400">
|
||||||
|
🔧 Out for Calibration
|
||||||
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
📦 Benched
|
📦 Benched
|
||||||
|
|||||||
@@ -87,10 +87,7 @@ async function deleteReservation(id, name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function editReservation(id) {
|
// editReservation is defined in fleet_calendar.html
|
||||||
// For now, just show alert - can implement edit modal later
|
|
||||||
alert('Edit functionality coming soon. For now, delete and recreate the reservation.');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
<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>
|
<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>
|
</div>
|
||||||
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<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>
|
</svg>
|
||||||
Deployed
|
Deployed
|
||||||
</span>
|
</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 %}
|
{% 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">
|
<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">
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
<option value="">All Status</option>
|
<option value="">All Status</option>
|
||||||
<option value="deployed">Deployed</option>
|
<option value="deployed">Deployed</option>
|
||||||
<option value="benched">Benched</option>
|
<option value="benched">Benched</option>
|
||||||
|
<option value="out_for_calibration">Out for Calibration</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Modem Filter -->
|
<!-- Modem Filter -->
|
||||||
|
|||||||
@@ -41,6 +41,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Danger Zone
|
Danger Zone
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- General Tab -->
|
<!-- General Tab -->
|
||||||
@@ -514,6 +520,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<style>
|
||||||
.settings-tab {
|
.settings-tab {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
<p id="deployedStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
<p id="deployedStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p id="retiredStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,18 +497,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Checkboxes -->
|
<!-- Status Checkboxes -->
|
||||||
<div class="flex items-center gap-6 border-t border-gray-200 dark:border-gray-700 pt-4">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 space-y-3">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<div class="flex items-center gap-6">
|
||||||
<input type="checkbox" name="deployed" id="deployed" value="true"
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
<input type="checkbox" name="deployed" id="deployed" value="true"
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
</label>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
</label>
|
||||||
<input type="checkbox" name="retired" id="retired" value="true"
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
<input type="checkbox" name="out_for_calibration" id="outForCalibration" value="true"
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Retired</span>
|
class="w-4 h-4 text-purple-600 focus:ring-purple-500 rounded">
|
||||||
</label>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
@@ -817,7 +827,16 @@ function populateViewMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
|
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
|
// Basic info
|
||||||
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
||||||
@@ -1009,7 +1028,9 @@ function populateEditForm() {
|
|||||||
document.getElementById('address').value = currentUnit.address || '';
|
document.getElementById('address').value = currentUnit.address || '';
|
||||||
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
||||||
document.getElementById('deployed').checked = currentUnit.deployed;
|
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 || '';
|
document.getElementById('note').value = currentUnit.note || '';
|
||||||
|
|
||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
@@ -1153,6 +1174,25 @@ function cancelEdit() {
|
|||||||
populateEditForm();
|
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
|
// Handle form submission
|
||||||
document.getElementById('editForm').addEventListener('submit', async function(e) {
|
document.getElementById('editForm').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
Reference in New Issue
Block a user